diff --git a/CHANGELOG.md b/CHANGELOG.md
index 61b0c8f0b6..ce73cd6a91 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -5,14 +5,19 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## Unreleased
-
### Added
- Introduced merge detection for receive hooks ([#1278](https://github.com/scm-manager/scm-manager/pull/1278))
+- Anonymous mode for the web ui ([#1284](https://github.com/scm-manager/scm-manager/pull/1284))
+- Add link to source file in diff sections ([#1267](https://github.com/scm-manager/scm-manager/pull/1267))
- Check versions of plugin dependencies on plugin installation ([#1283](https://github.com/scm-manager/scm-manager/pull/1283))
+- Sign PR merges and commits performed through ui with generated private key ([#1285](https://github.com/scm-manager/scm-manager/pull/1285))
+- Add generic popover component to ui-components ([#1285](https://github.com/scm-manager/scm-manager/pull/1285))
+- Show changeset signatures in ui and add public keys ([#1273](https://github.com/scm-manager/scm-manager/pull/1273))
### Fixed
- Repository names may not end with ".git" ([#1277](https://github.com/scm-manager/scm-manager/pull/1277))
- Add preselected value to options in dropdown component if missing ([#1287](https://github.com/scm-manager/scm-manager/pull/1287))
+- Show error message if plugin loading failed ([#1289](https://github.com/scm-manager/scm-manager/pull/1289))
- Fix timing problem with anchor links for markdown view ([#1290](https://github.com/scm-manager/scm-manager/pull/1290))
## [2.3.1] - 2020-08-04
@@ -20,13 +25,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
- New api to resolve SCM-Manager root url ([#1276](https://github.com/scm-manager/scm-manager/pull/1276))
### Changed
-- Help tooltips are now mutliline by default ([#1271](https://github.com/scm-manager/scm-manager/pull/1271))
+- Help tooltips are now multiline by default ([#1271](https://github.com/scm-manager/scm-manager/pull/1271))
### Fixed
- Fixed unnecessary horizontal scrollbar in modal dialogs ([#1271](https://github.com/scm-manager/scm-manager/pull/1271))
- Avoid stacktrace logging when protocol url is accessed outside of request scope ([#1276](https://github.com/scm-manager/scm-manager/pull/1276))
## [2.3.0] - 2020-07-23
+
### Added
- Add branch link provider to access branch links in plugins ([#1243](https://github.com/scm-manager/scm-manager/pull/1243))
- Add key value input field component ([#1246](https://github.com/scm-manager/scm-manager/pull/1246))
diff --git a/docs/de/user/admin/assets/administration-settings-general.png b/docs/de/user/admin/assets/administration-settings-general.png
index 3be3e6b715..60f981c8c6 100644
Binary files a/docs/de/user/admin/assets/administration-settings-general.png and b/docs/de/user/admin/assets/administration-settings-general.png differ
diff --git a/docs/de/user/admin/settings.md b/docs/de/user/admin/settings.md
index d26d8af3a2..82643a35bb 100644
--- a/docs/de/user/admin/settings.md
+++ b/docs/de/user/admin/settings.md
@@ -26,8 +26,9 @@ Um Angriffe auf den SCM-Manager mit Cross Site Scripting (XSS / XSRF) zu erschwe
#### Plugin-Center-URL
Der SCM-Manager kann ein Plugin-Center anbinden, um schnell und bequem Plugins verwalten zu können. Um ein anderes SCM-Plugin-Center als das vorkonfigurierte zu verwenden, reicht es aus diese URL zu ändern. Läuft der SCM-Manager im Cloudogu EcoSystem kann die Plugin Center URL über einen Eintrag im etcd gesetzt werden.
-#### Anonyme Zugriff erlauben
-Der SCM-Manager 2 hat das Konzept für anonyme Zugriffe über einen "_anonymous"-Benutzer realisiert. Beim Aktivieren des anonymen Zugriffs wird ein neuer Benutzer erstellt mit dem Namen "_anonymous". Dieser Nutzer kann wie ein gewöhnlicher Benutzer für unterschiedliche Aktionen berechtigt werden. Bei einem Zugriff auf den SCM-Manager ohne Zugangsdaten (gilt nicht für die Web-Oberflächen) wird dieser anonyme Benutzer verwendet.
+#### Anonyme Zugriff
+Der SCM-Manager 2 hat das Konzept für anonyme Zugriffe über einen "_anonymous"-Benutzer realisiert. Beim Aktivieren des anonymen Zugriffs wird ein neuer Benutzer erstellt mit dem Namen "_anonymous". Dieser Nutzer kann wie ein gewöhnlicher Benutzer für unterschiedliche Aktionen berechtigt werden. Bei einem Zugriff auf den SCM-Manager ohne Zugangsdaten wird dieser anonyme Benutzer verwendet.
+Ist der anonyme Zugriff nur für Protokoll aktiviert, können die REST API und die VCS Protokolle anonym genutzt werden. Wurde der anonyme Zugriff vollständig aktiviert, ist auch ein Zugriff über den Webclient anonym möglich.
Beispiel: Falls der anonyme Zugriff aktiviert ist und der "_anonymous"-Benutzer volle Zugriffsrechte auf ein bestimmtes Git-Repository hat, kann jeder über eine Kommandozeile mit den klassischen Git-Befehlen ohne Zugangsdaten auf dieses Repository zugreifen. Zugriffe über SSH werden aktuell nicht unterstützt.
diff --git a/docs/de/user/repo/assets/repository-code-changesetDetails.png b/docs/de/user/repo/assets/repository-code-changesetDetails.png
index f576e0736a..b3bce461c0 100644
Binary files a/docs/de/user/repo/assets/repository-code-changesetDetails.png and b/docs/de/user/repo/assets/repository-code-changesetDetails.png differ
diff --git a/docs/de/user/repo/assets/repository-code-changesetsView.png b/docs/de/user/repo/assets/repository-code-changesetsView.png
index 4974525c4e..446babec5c 100644
Binary files a/docs/de/user/repo/assets/repository-code-changesetsView.png and b/docs/de/user/repo/assets/repository-code-changesetsView.png differ
diff --git a/docs/de/user/repo/code.md b/docs/de/user/repo/code.md
index 9ec3f29938..a779564ea0 100644
--- a/docs/de/user/repo/code.md
+++ b/docs/de/user/repo/code.md
@@ -17,6 +17,8 @@ Die Übersicht der Changesets/Commits zeigt die Änderungshistorie je Branch an.
Über den Details-Button kann man sich den Inhalt / die Änderungen dieses Changesets ansehen.
+Der Schlüssel Icon zeigt an, ob ein Changeset signiert wurde. Um die Signatur zu validieren, können die Benutzer ihre öffentlichen Schlüssel (Public Keys) im SCM-Manager hinterlegen. Ein grüner Schlüssel bedeutet die Signatur konnte erfolgreich gegen einen hinterlegten öffentlichen Schlüssel im SCM-Manager verifiziert werden. Ein grauer Schlüssel heißt, dass die Signatur zu keinem Schlüssel im SCM-Manager passt. Und ein roter Schlüssel warnt vor einer ungültigen (möglicherweise gefälschten) Signatur.
+
Über den Sources-Button gelangt man zur Sources-Übersicht und es wird der Datenstand zum Zeitpunkt nach diesem Commit angezeigt.

diff --git a/docs/de/user/user/assets/user-settings-publickeys.png b/docs/de/user/user/assets/user-settings-publickeys.png
new file mode 100644
index 0000000000..1608e46b04
Binary files /dev/null and b/docs/de/user/user/assets/user-settings-publickeys.png differ
diff --git a/docs/de/user/user/settings.md b/docs/de/user/user/settings.md
index d9ec76446b..d09de0b98e 100644
--- a/docs/de/user/user/settings.md
+++ b/docs/de/user/user/settings.md
@@ -19,3 +19,8 @@ Hier werden die globalen (nicht-Repository-bezogenen) Berechtigungen für einen
Für die einzelnen Rechte sind Tooltips verfügbar, welche Auskunft über die Auswirkungen der jeweiligen Berechtigung geben.

+
+### Öffentliche Schlüssel
+Es können öffentliche Schlüssel (Public Keys) zu Benutzern hinzugefügt werden, um die Changeset Signaturen damit zu verifizieren.
+
+
diff --git a/docs/en/user/admin/assets/administration-settings-general.png b/docs/en/user/admin/assets/administration-settings-general.png
index c29652e101..34be1e5664 100644
Binary files a/docs/en/user/admin/assets/administration-settings-general.png and b/docs/en/user/admin/assets/administration-settings-general.png differ
diff --git a/docs/en/user/admin/settings.md b/docs/en/user/admin/settings.md
index 6f45422ca3..96a606bad0 100644
--- a/docs/en/user/admin/settings.md
+++ b/docs/en/user/admin/settings.md
@@ -26,8 +26,9 @@ Activate this option to make attacks using cross site scripting (XSS / XSRF) on
#### Plugin Center URL
A plugin center can be used to conveniently manage plugins. If you want to use a plugin center that is not the default one, you only have to change this URL. If SCM-Manager is operated as part of a Cloudogu EcoSystem, the plugin center URL can be changed in the etcd.
-#### Enable Anonymous Access
-In SCM-Manager 2 the access for anonymous access is realized by using an "_anonymous" user. When the feature is activated, a new user with the name "_anonymous" is created. This user can be authorized just like any other user. This user is used for access to SCM-Manager without login credentials (this does not apply to access via web UI).
+#### Anonymous Access
+In SCM-Manager 2 the access for anonymous access is realized by using an "_anonymous" user. When the feature is activated, a new user with the name "_anonymous" is created. This user can be authorized just like any other user. This user is used for access to SCM-Manager without login credentials.
+If the anonymous mode is protocol only you may access the SCM-Manager via the REST API and VCS protocols. With fully enabled anonymous access you can also use the webclient without credentials.
Example: If anonymous access is enabled and the "_anonymous" user has full access on a certain Git repository, everybody can access this repository via command line and the classic Git commands without any login credentials. Access via SSH is not supported at this time.
diff --git a/docs/en/user/repo/assets/repository-code-changesetDetails.png b/docs/en/user/repo/assets/repository-code-changesetDetails.png
index c157813470..cfcdd88939 100644
Binary files a/docs/en/user/repo/assets/repository-code-changesetDetails.png and b/docs/en/user/repo/assets/repository-code-changesetDetails.png differ
diff --git a/docs/en/user/repo/assets/repository-code-changesetsView.png b/docs/en/user/repo/assets/repository-code-changesetsView.png
index 4b466543d6..4cb8f63736 100644
Binary files a/docs/en/user/repo/assets/repository-code-changesetsView.png and b/docs/en/user/repo/assets/repository-code-changesetsView.png differ
diff --git a/docs/en/user/repo/code.md b/docs/en/user/repo/code.md
index b0a8995c88..b0037d563b 100644
--- a/docs/en/user/repo/code.md
+++ b/docs/en/user/repo/code.md
@@ -17,6 +17,8 @@ The changesets/commits overview shows the change history of the branch. Each ent
The Details button leads to the content/changes of a changeset.
+The key icon shows if the changeset was signed. The users can add their public keys to SCM-Manager for signature verification. The green key means that the signature could be verified successfully against an existing public key. The grey key shows that no matching key could be found for the signature. The red key warns you about an invalid (possible faked) signature.
+
The Sources button leads to the sources overview that shows the state from after this commit.

diff --git a/docs/en/user/user/assets/user-settings-publickeys.png b/docs/en/user/user/assets/user-settings-publickeys.png
new file mode 100644
index 0000000000..498fe1e336
Binary files /dev/null and b/docs/en/user/user/assets/user-settings-publickeys.png differ
diff --git a/docs/en/user/user/settings.md b/docs/en/user/user/settings.md
index ec37893bab..328fe414af 100644
--- a/docs/en/user/user/settings.md
+++ b/docs/en/user/user/settings.md
@@ -19,3 +19,8 @@ In the permissions section, the global, therefore not repository-specific permis
There is a tooltip for each permission that provide some more details about the option.

+
+### Public keys
+Add public keys to users to enable changeset signature verification.
+
+
diff --git a/pom.xml b/pom.xml
index c3b5a43318..11ad3f3ff2 100644
--- a/pom.xml
+++ b/pom.xml
@@ -525,6 +525,26 @@
1.14
+
+
+
+ org.bouncycastle
+ bcpg-jdk15on
+ ${bouncycastle.version}
+
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+ ${bouncycastle.version}
+
+
+
+ org.bouncycastle
+ bcpkix-jdk15on
+ ${bouncycastle.version}
+
+
@@ -595,7 +615,7 @@
org.apache.maven.pluginsmaven-enforcer-plugin
- 3.0.0-M1
+ 3.0.0-M3enforce-java
@@ -639,7 +659,7 @@
org.codehaus.mojoextra-enforcer-rules
- 1.0-beta-7
+ 1.3
@@ -899,6 +919,7 @@
4.2.32.3.36.1.5.Final
+ 1.651.6.2
diff --git a/scm-core/pom.xml b/scm-core/pom.xml
index f7df981723..9d4f7e388f 100644
--- a/scm-core/pom.xml
+++ b/scm-core/pom.xml
@@ -112,6 +112,12 @@
${guice.version}
+
+ com.google.inject.extensions
+ guice-assistedinject
+ ${guice.version}
+
+
diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java
index bd94f9e33f..823df43582 100644
--- a/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java
+++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/ChangesetDto.java
@@ -61,6 +61,8 @@ public class ChangesetDto extends HalRepresentation {
private List contributors;
+ private List signatures;
+
public ChangesetDto(Links links, Embedded embedded) {
super(links, embedded);
}
diff --git a/scm-core/src/main/java/sonia/scm/api/v2/resources/SignatureDto.java b/scm-core/src/main/java/sonia/scm/api/v2/resources/SignatureDto.java
new file mode 100644
index 0000000000..e25943e025
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/api/v2/resources/SignatureDto.java
@@ -0,0 +1,54 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Links;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+import sonia.scm.repository.Person;
+import sonia.scm.repository.SignatureStatus;
+
+import java.util.Optional;
+import java.util.Set;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@SuppressWarnings("squid:S2160")
+public class SignatureDto extends HalRepresentation {
+
+ private String keyId;
+ private String type;
+ private SignatureStatus status;
+ private Optional owner;
+ private Set contacts;
+
+ public SignatureDto(Links links) {
+ super(links);
+ }
+
+}
diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java
index 50202a3410..3a69917df1 100644
--- a/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java
+++ b/scm-core/src/main/java/sonia/scm/config/ScmConfiguration.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.config;
@@ -30,6 +30,7 @@ import com.google.inject.Singleton;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.event.ScmEventBus;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.util.HttpUtil;
import sonia.scm.xml.XmlSetStringAdapter;
@@ -161,7 +162,7 @@ public class ScmConfiguration implements Configuration {
* @see http://momentjs.com/docs/#/parsing/
*/
private String dateFormat = DEFAULT_DATEFORMAT;
- private boolean anonymousAccessEnabled = false;
+ private AnonymousMode anonymousMode = AnonymousMode.OFF;
/**
* Enables xsrf cookie protection.
@@ -200,7 +201,7 @@ public class ScmConfiguration implements Configuration {
this.realmDescription = other.realmDescription;
this.dateFormat = other.dateFormat;
this.pluginUrl = other.pluginUrl;
- this.anonymousAccessEnabled = other.anonymousAccessEnabled;
+ this.anonymousMode = other.anonymousMode;
this.enableProxy = other.enableProxy;
this.proxyPort = other.proxyPort;
this.proxyServer = other.proxyServer;
@@ -311,8 +312,24 @@ public class ScmConfiguration implements Configuration {
return realmDescription;
}
+ /**
+ * Returns the currently enabled type of anonymous mode.
+ *
+ * @return anonymous mode
+ * @since 2.4.0
+ */
+ public AnonymousMode getAnonymousMode() {
+ return anonymousMode;
+ }
+
+ /**
+ * Returns {@code true} if anonymous mode is enabled.
+ * @return {@code true} if anonymous mode is enabled
+ * @deprecated since 2.4.0 use {@link ScmConfiguration#getAnonymousMode} instead
+ */
+ @Deprecated
public boolean isAnonymousAccessEnabled() {
- return anonymousAccessEnabled;
+ return anonymousMode != AnonymousMode.OFF;
}
public boolean isDisableGroupingGrid() {
@@ -360,8 +377,28 @@ public class ScmConfiguration implements Configuration {
return skipFailedAuthenticators;
}
+ /**
+ * Enables the anonymous access at protocol level.
+ * @param anonymousAccessEnabled enable or disables the anonymous access
+ * @deprecated since 2.4.0 use {@link ScmConfiguration#setAnonymousMode(AnonymousMode)} instead
+ */
+ @Deprecated
public void setAnonymousAccessEnabled(boolean anonymousAccessEnabled) {
- this.anonymousAccessEnabled = anonymousAccessEnabled;
+ if (anonymousAccessEnabled) {
+ this.anonymousMode = AnonymousMode.PROTOCOL_ONLY;
+ } else {
+ this.anonymousMode = AnonymousMode.OFF;
+ }
+ }
+
+ /**
+ * Configures the anonymous mode.
+ * @param mode type of anonymous mode
+ *
+ * @since 2.4.0
+ */
+ public void setAnonymousMode(AnonymousMode mode) {
+ this.anonymousMode = mode;
}
public void setBaseUrl(String baseUrl) {
diff --git a/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java b/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java
index 7079c8fc15..48912b5179 100644
--- a/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java
+++ b/scm-core/src/main/java/sonia/scm/config/ScmConfigurationChangedListener.java
@@ -29,6 +29,7 @@ import com.google.inject.Inject;
import sonia.scm.EagerSingleton;
import sonia.scm.SCMContext;
import sonia.scm.plugin.Extension;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.user.UserManager;
@Extension
@@ -48,7 +49,7 @@ public class ScmConfigurationChangedListener {
}
private void createAnonymousUserIfRequired(ScmConfigurationChangedEvent event) {
- if (event.getConfiguration().isAnonymousAccessEnabled() && !userManager.contains(SCMContext.USER_ANONYMOUS)) {
+ if (event.getConfiguration().getAnonymousMode() != AnonymousMode.OFF && !userManager.contains(SCMContext.USER_ANONYMOUS)) {
userManager.create(SCMContext.ANONYMOUS);
}
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/Changeset.java b/scm-core/src/main/java/sonia/scm/repository/Changeset.java
index e2c980db37..914fd8f90a 100644
--- a/scm-core/src/main/java/sonia/scm/repository/Changeset.java
+++ b/scm-core/src/main/java/sonia/scm/repository/Changeset.java
@@ -32,6 +32,7 @@ import sonia.scm.util.ValidationUtil;
import java.util.ArrayList;
import java.util.Collection;
+import java.util.Collections;
import java.util.Date;
import java.util.List;
@@ -85,6 +86,8 @@ public class Changeset extends BasicPropertiesAware implements ModelObject {
*/
private Collection contributors;
+ private List signatures = new ArrayList<>();
+
public Changeset() {}
public Changeset(String id, Long date, Person author)
@@ -348,4 +351,31 @@ public class Changeset extends BasicPropertiesAware implements ModelObject {
this.contributors.addAll(contributors);
}
}
+
+ /**
+ * Sets a collection of signatures which belong to this changeset.
+ * @param signatures collection of signatures
+ * @since 2.4.0
+ */
+ public void setSignatures(Collection signatures) {
+ this.signatures = new ArrayList<>(signatures);
+ }
+
+ /**
+ * Returns a immutable list of signatures.
+ * @return signatures
+ * @since 2.4.0
+ */
+ public List getSignatures() {
+ return Collections.unmodifiableList(signatures);
+ }
+
+ /**
+ * Adds a signature to the list of signatures.
+ * @param signature
+ * @since 2.4.0
+ */
+ public void addSignature(Signature signature) {
+ signatures.add(signature);
+ }
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/Signature.java b/scm-core/src/main/java/sonia/scm/repository/Signature.java
new file mode 100644
index 0000000000..f0f3e1e492
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/Signature.java
@@ -0,0 +1,53 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.repository;
+
+import lombok.Value;
+
+import java.io.Serializable;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * Signature is the output of a signature verification.
+ *
+ * @since 2.4.0
+ */
+@Value
+public class Signature implements Serializable {
+
+ private static final long serialVersionUID = 1L;
+
+ private final String keyId;
+ private final String type;
+ private final SignatureStatus status;
+ private final String owner;
+ private final Set contacts;
+
+ public Optional getOwner() {
+ return Optional.ofNullable(owner);
+ }
+
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/SignatureStatus.java b/scm-core/src/main/java/sonia/scm/repository/SignatureStatus.java
new file mode 100644
index 0000000000..73961353ea
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/repository/SignatureStatus.java
@@ -0,0 +1,32 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.repository;
+
+/**
+ * @since 2.4.0
+ */
+public enum SignatureStatus {
+ VERIFIED, NOT_FOUND, INVALID;
+}
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java
index f704b16b3d..55e1f44f3c 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/MergeCommandBuilder.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.repository.api;
import com.google.common.base.Preconditions;
@@ -137,6 +137,16 @@ public class MergeCommandBuilder {
return this;
}
+ /**
+ * Disables adding a verifiable signature to the merge commit.
+ * @return This builder instance.
+ * @since 2.4.0
+ */
+ public MergeCommandBuilder disableSigning() {
+ request.setSign(false);
+ return this;
+ }
+
/**
* Use this to set the strategy of the merge commit manually.
*
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java
index 3067d7a054..aeccb24b99 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/ModifyCommandBuilder.java
@@ -164,6 +164,16 @@ public class ModifyCommandBuilder {
return this;
}
+ /**
+ * Disables adding a verifiable signature to the modification commit.
+ * @return This builder instance.
+ * @since 2.4.0
+ */
+ public ModifyCommandBuilder disableSigning() {
+ request.setSign(false);
+ return this;
+ }
+
/**
* Set the expected revision of the branch, before the changes are applied. If the branch does not have the
* expected revision, a concurrent modification exception will be thrown when the command is executed and no
diff --git a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
index 31ca9ca15d..d7074b7f4b 100644
--- a/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
+++ b/scm-core/src/main/java/sonia/scm/repository/api/RepositoryServiceFactory.java
@@ -55,6 +55,8 @@ import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.spi.RepositoryServiceProvider;
import sonia.scm.repository.spi.RepositoryServiceResolver;
import sonia.scm.repository.work.WorkdirProvider;
+import sonia.scm.security.PublicKeyCreatedEvent;
+import sonia.scm.security.PublicKeyDeletedEvent;
import sonia.scm.security.ScmSecurityException;
import java.util.Set;
@@ -100,14 +102,12 @@ import static sonia.scm.NotFoundException.notFound;
*
*
* @author Sebastian Sdorra
- * @since 1.17
- *
* @apiviz.landmark
* @apiviz.uses sonia.scm.repository.api.RepositoryService
+ * @since 1.17
*/
@Singleton
-public final class RepositoryServiceFactory
-{
+public final class RepositoryServiceFactory {
/**
* the logger for RepositoryServiceFactory
@@ -122,12 +122,11 @@ public final class RepositoryServiceFactory
* should not be called manually, it should only be used by the injection
* container.
*
- *
- * @param configuration configuration
- * @param cacheManager cache manager
+ * @param configuration configuration
+ * @param cacheManager cache manager
* @param repositoryManager manager for repositories
- * @param resolvers a set of {@link RepositoryServiceResolver}
- * @param preProcessorUtil helper object for pre processor handling
+ * @param resolvers a set of {@link RepositoryServiceResolver}
+ * @param preProcessorUtil helper object for pre processor handling
* @param protocolProviders
* @param workdirProvider
* @since 1.21
@@ -136,8 +135,7 @@ public final class RepositoryServiceFactory
public RepositoryServiceFactory(ScmConfiguration configuration,
CacheManager cacheManager, RepositoryManager repositoryManager,
Set resolvers, PreProcessorUtil preProcessorUtil,
- Set protocolProviders, WorkdirProvider workdirProvider)
- {
+ @SuppressWarnings("rawtypes") Set protocolProviders, WorkdirProvider workdirProvider) {
this(
configuration, cacheManager, repositoryManager, resolvers,
preProcessorUtil, protocolProviders, workdirProvider, ScmEventBus.getInstance()
@@ -146,11 +144,10 @@ public final class RepositoryServiceFactory
@VisibleForTesting
RepositoryServiceFactory(ScmConfiguration configuration,
- CacheManager cacheManager, RepositoryManager repositoryManager,
- Set resolvers, PreProcessorUtil preProcessorUtil,
- Set protocolProviders, WorkdirProvider workdirProvider,
- ScmEventBus eventBus)
- {
+ CacheManager cacheManager, RepositoryManager repositoryManager,
+ Set resolvers, PreProcessorUtil preProcessorUtil,
+ Set protocolProviders, WorkdirProvider workdirProvider,
+ ScmEventBus eventBus) {
this.configuration = configuration;
this.cacheManager = cacheManager;
this.repositoryManager = repositoryManager;
@@ -167,19 +164,16 @@ public final class RepositoryServiceFactory
/**
* Creates a new RepositoryService for the given repository.
*
- *
* @param repositoryId id of the repository
- *
* @return a implementation of RepositoryService
- * for the given type of repository
- *
- * @throws NotFoundException if no repository
- * with the given id is available
+ * for the given type of repository
+ * @throws NotFoundException if no repository
+ * with the given id is available
* @throws RepositoryServiceNotFoundException if no repository service
- * implementation for this kind of repository is available
- * @throws IllegalArgumentException if the repository id is null or empty
- * @throws ScmSecurityException if current user has not read permissions
- * for that repository
+ * implementation for this kind of repository is available
+ * @throws IllegalArgumentException if the repository id is null or empty
+ * @throws ScmSecurityException if current user has not read permissions
+ * for that repository
*/
public RepositoryService create(String repositoryId) {
Preconditions.checkArgument(!Strings.isNullOrEmpty(repositoryId),
@@ -187,8 +181,7 @@ public final class RepositoryServiceFactory
Repository repository = repositoryManager.get(repositoryId);
- if (repository == null)
- {
+ if (repository == null) {
throw new NotFoundException(Repository.class, repositoryId);
}
@@ -198,29 +191,24 @@ public final class RepositoryServiceFactory
/**
* Creates a new RepositoryService for the given repository.
*
- *
* @param namespaceAndName namespace and name of the repository
- *
* @return a implementation of RepositoryService
- * for the given type of repository
- *
- * @throws NotFoundException if no repository
- * with the given id is available
+ * for the given type of repository
+ * @throws NotFoundException if no repository
+ * with the given id is available
* @throws RepositoryServiceNotFoundException if no repository service
- * implementation for this kind of repository is available
- * @throws IllegalArgumentException if one of the parameters is null or empty
- * @throws ScmSecurityException if current user has not read permissions
- * for that repository
+ * implementation for this kind of repository is available
+ * @throws IllegalArgumentException if one of the parameters is null or empty
+ * @throws ScmSecurityException if current user has not read permissions
+ * for that repository
*/
- public RepositoryService create(NamespaceAndName namespaceAndName)
- {
+ public RepositoryService create(NamespaceAndName namespaceAndName) {
Preconditions.checkArgument(namespaceAndName != null,
"a non empty namespace and name is required");
Repository repository = repositoryManager.get(namespaceAndName);
- if (repository == null)
- {
+ if (repository == null) {
throw notFound(entity(namespaceAndName));
}
@@ -230,20 +218,16 @@ public final class RepositoryServiceFactory
/**
* Creates a new RepositoryService for the given repository.
*
- *
* @param repository the repository
- *
* @return a implementation of RepositoryService
- * for the given type of repository
- *
+ * for the given type of repository
* @throws RepositoryServiceNotFoundException if no repository service
- * implementation for this kind of repository is available
- * @throws NullPointerException if the repository is null
- * @throws ScmSecurityException if current user has not read permissions
- * for that repository
+ * implementation for this kind of repository is available
+ * @throws NullPointerException if the repository is null
+ * @throws ScmSecurityException if current user has not read permissions
+ * for that repository
*/
- public RepositoryService create(Repository repository)
- {
+ public RepositoryService create(Repository repository) {
Preconditions.checkNotNull(repository, "repository is required");
// check for read permissions of current user
@@ -251,14 +235,11 @@ public final class RepositoryServiceFactory
RepositoryService service = null;
- for (RepositoryServiceResolver resolver : resolvers)
- {
+ for (RepositoryServiceResolver resolver : resolvers) {
RepositoryServiceProvider provider = resolver.resolve(repository);
- if (provider != null)
- {
- if (logger.isDebugEnabled())
- {
+ if (provider != null) {
+ if (logger.isDebugEnabled()) {
logger.debug(
"create new repository service for repository {} of type {}",
repository.getName(), repository.getType());
@@ -271,8 +252,7 @@ public final class RepositoryServiceFactory
}
}
- if (service == null)
- {
+ if (service == null) {
throw new RepositoryServiceNotFoundException(repository);
}
@@ -284,8 +264,7 @@ public final class RepositoryServiceFactory
/**
* Hook and listener to clear all relevant repository caches.
*/
- private static class CacheClearHook
- {
+ private static class CacheClearHook {
private final Set> caches = Sets.newHashSet();
private final CacheManager cacheManager;
@@ -296,8 +275,7 @@ public final class RepositoryServiceFactory
*
* @param cacheManager cache manager
*/
- public CacheClearHook(CacheManager cacheManager)
- {
+ public CacheClearHook(CacheManager cacheManager) {
this.cacheManager = cacheManager;
this.caches.add(cacheManager.getCache(BlameCommandBuilder.CACHE_NAME));
this.caches.add(cacheManager.getCache(BrowseCommandBuilder.CACHE_NAME));
@@ -324,12 +302,10 @@ public final class RepositoryServiceFactory
* @param event hook event
*/
@Subscribe(referenceType = ReferenceType.STRONG)
- public void onEvent(PostReceiveRepositoryHookEvent event)
- {
+ public void onEvent(PostReceiveRepositoryHookEvent event) {
Repository repository = event.getRepository();
- if (repository != null)
- {
+ if (repository != null) {
String id = repository.getId();
clearCaches(id);
@@ -342,10 +318,8 @@ public final class RepositoryServiceFactory
* @param event repository event
*/
@Subscribe(referenceType = ReferenceType.STRONG)
- public void onEvent(RepositoryEvent event)
- {
- if (event.getEventType() == HandlerEventType.DELETE)
- {
+ public void onEvent(RepositoryEvent event) {
+ if (event.getEventType() == HandlerEventType.DELETE) {
clearCaches(event.getItem().getId());
}
}
@@ -357,37 +331,53 @@ public final class RepositoryServiceFactory
cacheManager.getCache(BranchesCommandBuilder.CACHE_NAME).removeAll(predicate);
}
+ @Subscribe
+ public void onEvent(PublicKeyDeletedEvent event) {
+ cacheManager.getCache(LogCommandBuilder.CACHE_NAME).clear();
+ }
+
+ @Subscribe
+ public void onEvent(PublicKeyCreatedEvent event) {
+ cacheManager.getCache(LogCommandBuilder.CACHE_NAME).clear();
+ }
+
@SuppressWarnings({"unchecked", "java:S3740", "rawtypes"})
- private void clearCaches(final String repositoryId)
- {
- if (logger.isDebugEnabled())
- {
+ private void clearCaches(final String repositoryId) {
+ if (logger.isDebugEnabled()) {
logger.debug("clear caches for repository id {}", repositoryId);
}
RepositoryCacheKeyPredicate predicate = new RepositoryCacheKeyPredicate(repositoryId);
- caches.forEach((cache) -> {
- cache.removeAll(predicate);
- });
+ caches.forEach(cache -> cache.removeAll(predicate));
}
}
//~--- fields ---------------------------------------------------------------
- /** cache manager */
+ /**
+ * cache manager
+ */
private final CacheManager cacheManager;
- /** scm-manager configuration */
+ /**
+ * scm-manager configuration
+ */
private final ScmConfiguration configuration;
- /** pre processor util */
+ /**
+ * pre processor util
+ */
private final PreProcessorUtil preProcessorUtil;
- /** repository manager */
+ /**
+ * repository manager
+ */
private final RepositoryManager repositoryManager;
- /** service resolvers */
+ /**
+ * service resolvers
+ */
private final Set resolvers;
private Set protocolProviders;
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
index 043f0f9648..5fa755668f 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/MergeCommandRequest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.repository.spi;
import com.google.common.base.MoreObjects;
@@ -43,6 +43,7 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl
private Person author;
private String messageTemplate;
private MergeStrategy mergeStrategy;
+ private boolean sign = true;
public String getBranchToMerge() {
return branchToMerge;
@@ -84,6 +85,14 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl
this.mergeStrategy = mergeStrategy;
}
+ public boolean isSign() {
+ return sign;
+ }
+
+ public void setSign(boolean sign) {
+ this.sign = sign;
+ }
+
public boolean isValid() {
return !Strings.isNullOrEmpty(getBranchToMerge())
&& !Strings.isNullOrEmpty(getTargetBranch());
@@ -92,6 +101,7 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl
public void reset() {
this.setBranchToMerge(null);
this.setTargetBranch(null);
+ this.setSign(true);
}
@Override
@@ -109,7 +119,8 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl
return Objects.equal(branchToMerge, other.branchToMerge)
&& Objects.equal(targetBranch, other.targetBranch)
&& Objects.equal(author, other.author)
- && Objects.equal(mergeStrategy, other.mergeStrategy);
+ && Objects.equal(mergeStrategy, other.mergeStrategy)
+ && Objects.equal(sign, other.sign);
}
@Override
@@ -124,6 +135,7 @@ public class MergeCommandRequest implements Validateable, Resetable, Serializabl
.add("targetBranch", targetBranch)
.add("author", author)
.add("mergeStrategy", mergeStrategy)
+ .add("sign", sign)
.toString();
}
}
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/ModificationsCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/ModificationsCommandRequest.java
index d0c05fd99d..507af8b514 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/ModificationsCommandRequest.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/ModificationsCommandRequest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.repository.spi;
diff --git a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java
index 8f077430df..e82df8c15a 100644
--- a/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java
+++ b/scm-core/src/main/java/sonia/scm/repository/spi/ModifyCommandRequest.java
@@ -49,6 +49,7 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit
private String branch;
private String expectedRevision;
private boolean defaultPath;
+ private boolean sign = true;
@Override
public void reset() {
@@ -57,6 +58,7 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit
commitMessage = null;
branch = null;
defaultPath = false;
+ sign = true;
}
public void addRequest(PartialRequest request) {
@@ -75,6 +77,10 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit
this.branch = branch;
}
+ public void setSign(boolean sign) {
+ this.sign = sign;
+ }
+
public List getRequests() {
return Collections.unmodifiableList(requests);
}
@@ -112,6 +118,10 @@ public class ModifyCommandRequest implements Resetable, Validateable, CommandWit
this.defaultPath = defaultPath;
}
+ public boolean isSign() {
+ return sign;
+ }
+
public interface PartialRequest {
void execute(ModifyCommand.Worker worker) throws IOException;
}
diff --git a/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java b/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java
new file mode 100644
index 0000000000..0ca7683d32
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/AnonymousMode.java
@@ -0,0 +1,33 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+/**
+ * Available modes for anonymous access
+ * @since 2.4.0
+ */
+public enum AnonymousMode {
+ FULL, PROTOCOL_ONLY, OFF
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/Authentications.java b/scm-core/src/main/java/sonia/scm/security/Authentications.java
index e2ddd1aae2..d99643ee60 100644
--- a/scm-core/src/main/java/sonia/scm/security/Authentications.java
+++ b/scm-core/src/main/java/sonia/scm/security/Authentications.java
@@ -29,6 +29,8 @@ import sonia.scm.SCMContext;
public class Authentications {
+ private Authentications() {}
+
public static boolean isAuthenticatedSubjectAnonymous() {
return isSubjectAnonymous((String) SecurityUtils.getSubject().getPrincipal());
}
diff --git a/scm-core/src/main/java/sonia/scm/security/GPG.java b/scm-core/src/main/java/sonia/scm/security/GPG.java
new file mode 100644
index 0000000000..2f75b8d5dc
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/GPG.java
@@ -0,0 +1,66 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import java.util.Optional;
+
+/**
+ * Allows signing and verification using gpg.
+ *
+ * @since 2.4.0
+ */
+public interface GPG {
+
+ /**
+ * Returns the id of the key from the given signature.
+ *
+ * @param signature signature
+ * @return public key id
+ */
+ String findPublicKeyId(byte[] signature);
+
+ /**
+ * Returns the public key with the given id or an empty optional.
+ *
+ * @param id id of public
+ * @return public key or empty optional
+ */
+ Optional findPublicKey(String id);
+
+ /**
+ * Returns all public keys assigned to the given username
+ *
+ * @param username username of the public key owner
+ * @return collection of public keys
+ */
+ Iterable findPublicKeysByUsername(String username);
+
+ /**
+ * Returns the default private key of the currently authenticated user.
+ *
+ * @return default private key
+ */
+ PrivateKey getPrivateKey();
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/NotPublicKeyException.java b/scm-core/src/main/java/sonia/scm/security/NotPublicKeyException.java
new file mode 100644
index 0000000000..3d8775a09a
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/NotPublicKeyException.java
@@ -0,0 +1,46 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import sonia.scm.BadRequestException;
+import sonia.scm.ContextEntry;
+
+import java.util.List;
+
+@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
+public class NotPublicKeyException extends BadRequestException {
+ public NotPublicKeyException(List context, String message) {
+ super(context, message);
+ }
+
+ public NotPublicKeyException(List context, String message, Exception cause) {
+ super(context, message, cause);
+ }
+
+ @Override
+ public String getCode() {
+ return "BxS5wX2v71";
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/PrivateKey.java b/scm-core/src/main/java/sonia/scm/security/PrivateKey.java
new file mode 100644
index 0000000000..0ae639a3db
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/PrivateKey.java
@@ -0,0 +1,57 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+
+/**
+ * Can be used to create signatures of data.
+ * @since 2.4.0
+ */
+public interface PrivateKey {
+
+ /**
+ * Returns the key's id.
+ * @return id
+ */
+ String getId();
+
+ /**
+ * Creates a signature for the given data.
+ * @param stream data stream to sign
+ * @return signature
+ */
+ byte[] sign(InputStream stream);
+
+ /**
+ * Creates a signature for the given data.
+ * @param data data to sign
+ * @return signature
+ */
+ default byte[] sign(byte[] data) {
+ return sign(new ByteArrayInputStream(data));
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKey.java b/scm-core/src/main/java/sonia/scm/security/PublicKey.java
new file mode 100644
index 0000000000..003863d696
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/PublicKey.java
@@ -0,0 +1,88 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import sonia.scm.repository.Person;
+
+import java.io.ByteArrayInputStream;
+import java.io.InputStream;
+import java.util.Optional;
+import java.util.Set;
+
+/**
+ * The public key can be used to verify signatures.
+ *
+ * @since 2.4.0
+ */
+public interface PublicKey {
+
+ /**
+ * Returns id of the public key.
+ *
+ * @return id of key
+ */
+ String getId();
+
+ /**
+ * Returns the username of the owner or an empty optional.
+ *
+ * @return owner or empty optional
+ */
+ Optional getOwner();
+
+ /**
+ * Returns raw of the public key.
+ *
+ * @return raw of key
+ */
+ String getRaw();
+
+ /**
+ * Returns the contacts of the publickey.
+ *
+ * @return owner or empty optional
+ */
+ Set getContacts();
+
+ /**
+ * Verifies that the signature is valid for the given data.
+ *
+ * @param stream stream of data to verify
+ * @param signature signature
+ * @return {@code true} if the signature is valid for the given data
+ */
+ boolean verify(InputStream stream, byte[] signature);
+
+ /**
+ * Verifies that the signature is valid for the given data.
+ *
+ * @param data data to verify
+ * @param signature signature
+ * @return {@code true} if the signature is valid for the given data
+ */
+ default boolean verify(byte[] data, byte[] signature) {
+ return verify(new ByteArrayInputStream(data), signature);
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKeyCreatedEvent.java b/scm-core/src/main/java/sonia/scm/security/PublicKeyCreatedEvent.java
new file mode 100644
index 0000000000..a598533088
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/PublicKeyCreatedEvent.java
@@ -0,0 +1,44 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import sonia.scm.event.Event;
+
+/**
+ * This event is fired when a public key was created in SCM-Manager.
+ * @since 2.4.0
+ */
+@Event
+public final class PublicKeyCreatedEvent {
+ private final PublicKey key;
+
+ public PublicKeyCreatedEvent(PublicKey key) {
+ this.key = key;
+ }
+
+ public PublicKey getKey() {
+ return key;
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/PublicKeyDeletedEvent.java b/scm-core/src/main/java/sonia/scm/security/PublicKeyDeletedEvent.java
new file mode 100644
index 0000000000..833af8dee2
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/PublicKeyDeletedEvent.java
@@ -0,0 +1,44 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import sonia.scm.event.Event;
+
+/**
+ * This event is fired when a public key was removed from SCM-Manager.
+ * @since 2.4.0
+ */
+@Event
+public final class PublicKeyDeletedEvent {
+ private final PublicKey key;
+
+ public PublicKeyDeletedEvent(PublicKey key) {
+ this.key = key;
+ }
+
+ public PublicKey getKey() {
+ return key;
+ }
+}
diff --git a/scm-core/src/main/java/sonia/scm/security/TokenExpiredException.java b/scm-core/src/main/java/sonia/scm/security/TokenExpiredException.java
new file mode 100644
index 0000000000..5f570277a0
--- /dev/null
+++ b/scm-core/src/main/java/sonia/scm/security/TokenExpiredException.java
@@ -0,0 +1,55 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security;
+
+import org.apache.shiro.authc.AuthenticationException;
+
+/**
+ * This exception is thrown if the session token is expired
+ * @since 2.4.0
+ */
+@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
+public class TokenExpiredException extends AuthenticationException {
+
+ /**
+ * Constructs a new SessionExpiredException.
+ *
+ * @param message the reason for the exception
+ */
+ public TokenExpiredException(String message) {
+ super(message);
+ }
+
+ /**
+ * Constructs a new SessionExpiredException.
+ *
+ * @param message the reason for the exception
+ * @param cause the underlying Throwable that caused this exception to be thrown.
+ */
+ public TokenExpiredException(String message, Throwable cause) {
+ super(message, cause);
+ }
+
+}
diff --git a/scm-core/src/main/java/sonia/scm/user/User.java b/scm-core/src/main/java/sonia/scm/user/User.java
index cd2afb282d..1b0a3b2388 100644
--- a/scm-core/src/main/java/sonia/scm/user/User.java
+++ b/scm-core/src/main/java/sonia/scm/user/User.java
@@ -50,7 +50,7 @@ import java.security.Principal;
@StaticPermissions(
value = "user",
globalPermissions = {"create", "list", "autocomplete"},
- permissions = {"read", "modify", "delete", "changePassword"},
+ permissions = {"read", "modify", "delete", "changePassword", "changePublicKeys"},
custom = true, customGlobal = true
)
@XmlRootElement(name = "users")
diff --git a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java
index 5a9b30ca4e..04ffe12e21 100644
--- a/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java
+++ b/scm-core/src/main/java/sonia/scm/web/filter/AuthenticationFilter.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.web.filter;
//~--- non-JDK imports --------------------------------------------------------
@@ -36,7 +36,9 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.security.AnonymousToken;
+import sonia.scm.security.TokenExpiredException;
import sonia.scm.util.HttpUtil;
import sonia.scm.util.Util;
import sonia.scm.web.WebTokenGenerator;
@@ -48,8 +50,6 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Set;
-//~--- JDK imports ------------------------------------------------------------
-
/**
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
* an {@link AuthenticationToken}.
@@ -58,25 +58,22 @@ import java.util.Set;
* @since 2.0.0
*/
@Singleton
-public class AuthenticationFilter extends HttpFilter
-{
+public class AuthenticationFilter extends HttpFilter {
- /** marker for failed authentication */
+ private static final Logger logger = LoggerFactory.getLogger(AuthenticationFilter.class);
+
+ /**
+ * marker for failed authentication
+ */
private static final String ATTRIBUTE_FAILED_AUTH = "sonia.scm.auth.failed";
- /** Field description */
- private static final String HEADER_AUTHORIZATION = "Authorization";
-
- /** the logger for AuthenticationFilter */
- private static final Logger logger =
- LoggerFactory.getLogger(AuthenticationFilter.class);
-
- //~--- constructors ---------------------------------------------------------
+ private final Set tokenGenerators;
+ protected ScmConfiguration configuration;
/**
* Constructs a new basic authenticaton filter.
*
- * @param configuration scm-manager global configuration
+ * @param configuration scm-manager global configuration
* @param tokenGenerators web token generators
*/
@Inject
@@ -85,47 +82,35 @@ public class AuthenticationFilter extends HttpFilter
this.tokenGenerators = tokenGenerators;
}
- //~--- methods --------------------------------------------------------------
-
/**
* Handles authentication, if a one of the {@link WebTokenGenerator} returns
* an {@link AuthenticationToken}.
*
- * @param request servlet request
+ * @param request servlet request
* @param response servlet response
- * @param chain filter chain
- *
+ * @param chain filter chain
* @throws IOException
* @throws ServletException
*/
@Override
- protected void doFilter(HttpServletRequest request,
- HttpServletResponse response, FilterChain chain)
- throws IOException, ServletException
- {
+ protected void doFilter(HttpServletRequest request, HttpServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
Subject subject = SecurityUtils.getSubject();
AuthenticationToken token = createToken(request);
- if (token != null)
- {
+ if (token != null) {
logger.trace(
"found authentication token on request, start authentication");
handleAuthentication(request, response, chain, subject, token);
- }
- else if (subject.isAuthenticated())
- {
+ } else if (subject.isAuthenticated()) {
logger.trace("user is already authenticated");
processChain(request, response, chain, subject);
- }
- else if (isAnonymousAccessEnabled() && !HttpUtil.isWUIRequest(request))
- {
+ } else if (isAnonymousAccessEnabled()) {
logger.trace("anonymous access granted");
subject.login(new AnonymousToken());
processChain(request, response, chain, subject);
- }
- else
- {
+ } else {
logger.trace("could not find user send unauthorized");
handleUnauthorized(request, response, chain);
}
@@ -135,28 +120,22 @@ public class AuthenticationFilter extends HttpFilter
* Sends status code 403 back to client, if the authentication has failed.
* In all other cases the method will send status code 403 back to client.
*
- * @param request servlet request
+ * @param request servlet request
* @param response servlet response
- * @param chain filter chain
- *
+ * @param chain filter chain
* @throws IOException
* @throws ServletException
- *
* @since 1.8
*/
protected void handleUnauthorized(HttpServletRequest request,
- HttpServletResponse response, FilterChain chain)
- throws IOException, ServletException
- {
+ HttpServletResponse response, FilterChain chain)
+ throws IOException, ServletException {
// send only forbidden, if the authentication has failed.
// see https://bitbucket.org/sdorra/scm-manager/issue/545/git-clone-with-username-in-url-does-not
- if (Boolean.TRUE.equals(request.getAttribute(ATTRIBUTE_FAILED_AUTH)))
- {
+ if (Boolean.TRUE.equals(request.getAttribute(ATTRIBUTE_FAILED_AUTH))) {
sendFailedAuthenticationError(request, response);
- }
- else
- {
+ } else {
sendUnauthorizedError(request, response);
}
}
@@ -164,16 +143,13 @@ public class AuthenticationFilter extends HttpFilter
/**
* Sends an error for a failed authentication back to client.
*
- *
- * @param request http request
+ * @param request http request
* @param response http response
- *
* @throws IOException
*/
protected void sendFailedAuthenticationError(HttpServletRequest request,
- HttpServletResponse response)
- throws IOException
- {
+ HttpServletResponse response)
+ throws IOException {
HttpUtil.sendUnauthorized(request, response,
configuration.getRealmDescription());
}
@@ -181,38 +157,27 @@ public class AuthenticationFilter extends HttpFilter
/**
* Sends an unauthorized error back to client.
*
- *
- * @param request http request
+ * @param request http request
* @param response http response
- *
* @throws IOException
*/
- protected void sendUnauthorizedError(HttpServletRequest request,
- HttpServletResponse response)
- throws IOException
- {
- HttpUtil.sendUnauthorized(request, response,
- configuration.getRealmDescription());
+ protected void sendUnauthorizedError(HttpServletRequest request, HttpServletResponse response) throws IOException {
+ HttpUtil.sendUnauthorized(request, response, configuration.getRealmDescription());
}
/**
* Iterates all {@link WebTokenGenerator} and creates an
* {@link AuthenticationToken} from the given request.
*
- *
* @param request http servlet request
- *
* @return authentication token of {@code null}
*/
- private AuthenticationToken createToken(HttpServletRequest request)
- {
+ private AuthenticationToken createToken(HttpServletRequest request) {
AuthenticationToken token = null;
- for (WebTokenGenerator generator : tokenGenerators)
- {
+ for (WebTokenGenerator generator : tokenGenerators) {
token = generator.createToken(request);
- if (token != null)
- {
+ if (token != null) {
logger.trace("generated web token {} from generator {}",
token.getClass(), generator.getClass());
@@ -226,30 +191,31 @@ public class AuthenticationFilter extends HttpFilter
/**
* Handle authentication with the given {@link AuthenticationToken}.
*
- *
- * @param request http servlet request
+ * @param request http servlet request
* @param response http servlet response
- * @param chain filter chain
- * @param subject subject
- * @param token authentication token
- *
+ * @param chain filter chain
+ * @param subject subject
+ * @param token authentication token
* @throws IOException
* @throws ServletException
*/
private void handleAuthentication(HttpServletRequest request,
- HttpServletResponse response, FilterChain chain, Subject subject,
- AuthenticationToken token)
- throws IOException, ServletException
- {
+ HttpServletResponse response, FilterChain chain, Subject subject,
+ AuthenticationToken token)
+ throws IOException, ServletException {
logger.trace("found basic authorization header, start authentication");
- try
- {
+ try {
subject.login(token);
processChain(request, response, chain, subject);
- }
- catch (AuthenticationException ex)
- {
+ } catch (TokenExpiredException ex) {
+ if (logger.isTraceEnabled()) {
+ logger.trace("{} expired", token.getClass(), ex);
+ } else {
+ logger.debug("{} expired", token.getClass());
+ }
+ handleUnauthorized(request, response, chain);
+ } catch (AuthenticationException ex) {
logger.warn("authentication failed", ex);
handleUnauthorized(request, response, chain);
}
@@ -258,33 +224,26 @@ public class AuthenticationFilter extends HttpFilter
/**
* Process the filter chain.
*
- *
- * @param request http servlet request
+ * @param request http servlet request
* @param response http servlet response
- * @param chain filter chain
- * @param subject subject
- *
+ * @param chain filter chain
+ * @param subject subject
* @throws IOException
* @throws ServletException
*/
private void processChain(HttpServletRequest request,
- HttpServletResponse response, FilterChain chain, Subject subject)
- throws IOException, ServletException
- {
+ HttpServletResponse response, FilterChain chain, Subject subject)
+ throws IOException, ServletException {
String username = Util.EMPTY_STRING;
- if (!subject.isAuthenticated())
- {
+ if (!subject.isAuthenticated()) {
// anonymous access
username = SCMContext.USER_ANONYMOUS;
- }
- else
- {
+ } else {
Object obj = subject.getPrincipal();
- if (obj != null)
- {
+ if (obj != null) {
username = obj.toString();
}
}
@@ -293,24 +252,12 @@ public class AuthenticationFilter extends HttpFilter
response);
}
- //~--- get methods ----------------------------------------------------------
-
/**
* Returns {@code true} if anonymous access is enabled.
*
- *
* @return {@code true} if anonymous access is enabled
*/
- private boolean isAnonymousAccessEnabled()
- {
- return (configuration != null) && configuration.isAnonymousAccessEnabled();
+ private boolean isAnonymousAccessEnabled() {
+ return (configuration != null) && configuration.getAnonymousMode() != AnonymousMode.OFF;
}
-
- //~--- fields ---------------------------------------------------------------
-
- /** set of web token generators */
- private final Set tokenGenerators;
-
- /** scm main configuration */
- protected ScmConfiguration configuration;
}
diff --git a/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java b/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java
index e95ebc2187..cc6bc4272f 100644
--- a/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java
+++ b/scm-core/src/test/java/sonia/scm/config/ScmConfigurationChangedListenerTest.java
@@ -29,6 +29,7 @@ import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.user.UserManager;
import static org.mockito.ArgumentMatchers.any;
@@ -52,7 +53,7 @@ class ScmConfigurationChangedListenerTest {
when(userManager.contains(any())).thenReturn(false);
ScmConfiguration changes = new ScmConfiguration();
- changes.setAnonymousAccessEnabled(true);
+ changes.setAnonymousMode(AnonymousMode.FULL);
scmConfiguration.load(changes);
listener.handleEvent(new ScmConfigurationChangedEvent(scmConfiguration));
@@ -64,7 +65,7 @@ class ScmConfigurationChangedListenerTest {
when(userManager.contains(any())).thenReturn(true);
ScmConfiguration changes = new ScmConfiguration();
- changes.setAnonymousAccessEnabled(true);
+ changes.setAnonymousMode(AnonymousMode.FULL);
scmConfiguration.load(changes);
listener.handleEvent(new ScmConfigurationChangedEvent(scmConfiguration));
diff --git a/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java b/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java
index a9349c83c9..6adf973b43 100644
--- a/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java
+++ b/scm-core/src/test/java/sonia/scm/web/filter/AuthenticationFilterTest.java
@@ -21,10 +21,8 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
-package sonia.scm.web.filter;
-//~--- non-JDK imports --------------------------------------------------------
+package sonia.scm.web.filter;
import com.github.sdorra.shiro.ShiroRule;
import com.github.sdorra.shiro.SubjectAware;
@@ -38,6 +36,7 @@ import org.junit.runner.RunWith;
import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.BearerToken;
import sonia.scm.web.WebTokenGenerator;
import javax.servlet.FilterChain;
@@ -47,191 +46,98 @@ import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import static org.mockito.Mockito.any;
+import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
-//~--- JDK imports ------------------------------------------------------------
-
-/**
- *
- * @author Sebastian Sdorra
- */
@RunWith(MockitoJUnitRunner.class)
@SubjectAware(configuration = "classpath:sonia/scm/shiro.ini")
-public class AuthenticationFilterTest
-{
+public class AuthenticationFilterTest {
+
+ @Rule
+ public ShiroRule shiro = new ShiroRule();
+
+ @Mock
+ private FilterChain chain;
+ @Mock
+ private HttpServletRequest request;
+ @Mock
+ private HttpServletResponse response;
+
+ private ScmConfiguration configuration;
- /**
- * Method description
- *
- *
- * @throws IOException
- * @throws ServletException
- */
@Test
@SubjectAware(username = "trillian", password = "secret")
- public void testDoFilterAuthenticated() throws IOException, ServletException
- {
+ public void testDoFilterAuthenticated() throws IOException, ServletException {
AuthenticationFilter filter = createAuthenticationFilter();
filter.doFilter(request, response, chain);
- verify(chain).doFilter(any(HttpServletRequest.class),
- any(HttpServletResponse.class));
+ verify(chain).doFilter(any(HttpServletRequest.class), any(HttpServletResponse.class));
}
- /**
- * Method description
- *
- *
- * @throws IOException
- * @throws ServletException
- */
@Test
- public void testDoFilterUnauthorized() throws IOException, ServletException
- {
+ public void testDoFilterUnauthorized() throws IOException, ServletException {
AuthenticationFilter filter = createAuthenticationFilter();
filter.doFilter(request, response, chain);
- verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED,
- "Authorization Required");
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required");
}
- /**
- * Method description
- *
- *
- * @throws IOException
- * @throws ServletException
- */
@Test
- public void testDoFilterWithAuthenticationFailed()
- throws IOException, ServletException
- {
- AuthenticationFilter filter =
- createAuthenticationFilter(new DemoWebTokenGenerator("trillian", "sec"));
+ public void testDoFilterWithAuthenticationFailed() throws IOException, ServletException {
+ AuthenticationFilter filter = createAuthenticationFilter(new DemoWebTokenGenerator("trillian", "sec"));
filter.doFilter(request, response, chain);
- verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED,
- "Authorization Required");
+
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required");
}
- /**
- * Method description
- *
- *
- * @throws IOException
- * @throws ServletException
- */
@Test
- public void testDoFilterWithAuthenticationSuccess()
- throws IOException, ServletException
- {
- AuthenticationFilter filter =
- createAuthenticationFilter(new DemoWebTokenGenerator("trillian",
- "secret"));
+ public void testDoFilterWithAuthenticationSuccess() throws IOException, ServletException {
+ AuthenticationFilter filter = createAuthenticationFilter();
filter.doFilter(request, response, chain);
- verify(chain).doFilter(any(HttpServletRequest.class),
- any(HttpServletResponse.class));
+
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required");
}
- //~--- set methods ----------------------------------------------------------
+ @Test
+ public void testExpiredBearerToken() throws IOException, ServletException {
+ WebTokenGenerator generator = mock(WebTokenGenerator.class);
+ when(generator.createToken(request)).thenReturn(BearerToken.create(null,
+ "eyJhbGciOiJIUzI1NiJ9.eyJzdWIiOiJzY21hZG1pbiIsImp0aSI6IjNqUzZ4TzMwUzEiLCJpYXQiOjE1OTY3ODA5Mjg"
+ + "sImV4cCI6MTU5Njc0NDUyOCwic2NtLW1hbmFnZXIucmVmcmVzaEV4cGlyYXRpb24iOjE1OTY4MjQxMjg2MDIsInNjbS1tYW5h"
+ + "Z2VyLnBhcmVudFRva2VuSWQiOiIzalM2eE8zMFMxIn0.utZLmzGZr-M6MP19yrd0dgLPkJ0u1xojwHKQi36_QAs"));
+ AuthenticationFilter filter = createAuthenticationFilter(generator);
+
+ filter.doFilter(request, response, chain);
+ verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, "Authorization Required");
+ }
- /**
- * Method description
- *
- */
@Before
- public void setUp()
- {
+ public void setUp() {
configuration = new ScmConfiguration();
}
- //~--- methods --------------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @param generators
- *
- * @return
- */
- private AuthenticationFilter createAuthenticationFilter(
- WebTokenGenerator... generators)
- {
- return new AuthenticationFilter(configuration,
- ImmutableSet.copyOf(generators));
+ private AuthenticationFilter createAuthenticationFilter(WebTokenGenerator... generators) {
+ return new AuthenticationFilter(configuration, ImmutableSet.copyOf(generators));
}
- //~--- inner classes --------------------------------------------------------
+ private static class DemoWebTokenGenerator implements WebTokenGenerator {
- /**
- * Class description
- *
- *
- * @version Enter version here..., 15/02/21
- * @author Enter your name here...
- */
- private static class DemoWebTokenGenerator implements WebTokenGenerator
- {
+ private final String username;
+ private final String password;
- /**
- * Constructs ...
- *
- *
- * @param username
- * @param password
- */
- public DemoWebTokenGenerator(String username, String password)
- {
+ public DemoWebTokenGenerator(String username, String password) {
this.username = username;
this.password = password;
}
- //~--- methods ------------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @param request
- *
- * @return
- */
@Override
- public AuthenticationToken createToken(HttpServletRequest request)
- {
+ public AuthenticationToken createToken(HttpServletRequest request) {
return new UsernamePasswordToken(username, password);
}
-
- //~--- fields -------------------------------------------------------------
-
- /** Field description */
- private final String password;
-
- /** Field description */
- private final String username;
}
- //~--- fields ---------------------------------------------------------------
-
- /** Field description */
- @Rule
- public ShiroRule shiro = new ShiroRule();
-
- /** Field description */
- @Mock
- private FilterChain chain;
-
- /** Field description */
- private ScmConfiguration configuration;
-
- /** Field description */
- @Mock
- private HttpServletRequest request;
-
- /** Field description */
- @Mock
- private HttpServletResponse response;
}
diff --git a/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java b/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java
index 0994ca0dcb..603942e4f7 100644
--- a/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java
+++ b/scm-it/src/test/java/sonia/scm/it/AnonymousAccessITCase.java
@@ -41,6 +41,7 @@ import sonia.scm.it.utils.ScmTypes;
import sonia.scm.it.utils.TestData;
import sonia.scm.repository.client.api.RepositoryClient;
import sonia.scm.repository.client.api.RepositoryClientException;
+import sonia.scm.security.AnonymousMode;
import javax.json.Json;
import javax.json.JsonArray;
@@ -77,10 +78,10 @@ class AnonymousAccessITCase {
@Nested
@TestInstance(TestInstance.Lifecycle.PER_CLASS)
- class WithAnonymousAccess {
+ class WithProtocolOnlyAnonymousAccess {
@BeforeAll
void enableAnonymousAccess() {
- setAnonymousAccess(true);
+ setAnonymousAccess(AnonymousMode.PROTOCOL_ONLY);
}
@BeforeEach
@@ -120,7 +121,7 @@ class AnonymousAccessITCase {
@BeforeEach
void grantAnonymousAccessToRepo() {
- ScmTypes.availableScmTypes().stream().forEach(type -> TestData.createUserPermission(USER_ANONYMOUS, WRITE, type));
+ ScmTypes.availableScmTypes().forEach(type -> TestData.createUserPermission(USER_ANONYMOUS, WRITE, type));
}
@ParameterizedTest
@@ -142,13 +143,84 @@ class AnonymousAccessITCase {
@AfterAll
void disableAnonymousAccess() {
- setAnonymousAccess(false);
+ setAnonymousAccess(AnonymousMode.OFF);
}
}
- private static void setAnonymousAccess(boolean anonymousAccessEnabled) {
+ @Nested
+ @TestInstance(TestInstance.Lifecycle.PER_CLASS)
+ class WithFullAnonymousAccess {
+ @BeforeAll
+ void enableAnonymousAccess() {
+ setAnonymousAccess(AnonymousMode.FULL);
+ }
+
+ @BeforeEach
+ void createRepository() {
+ TestData.createDefault();
+ }
+
+ @Test
+ void shouldGrantAnonymousAccessToRepositoryList() {
+ assertEquals(200, RestAssured.given()
+ .when()
+ .get(RestUtil.REST_BASE_URL.resolve("repositories"))
+ .statusCode());
+ }
+
+ @Nested
+ class WithoutAnonymousAccessForRepository {
+
+ @ParameterizedTest
+ @ArgumentsSource(ScmTypes.class)
+ void shouldGrantAnonymousAccessToRepository(String type) {
+ assertEquals(401, RestAssured.given()
+ .when()
+ .get(getDefaultRepositoryUrl(type))
+ .statusCode());
+ }
+
+ @ParameterizedTest
+ @ArgumentsSource(ScmTypes.class)
+ void shouldNotCloneRepository(String type, @TempDir Path temporaryFolder) {
+ assertThrows(RepositoryClientException.class, () -> RepositoryUtil.createAnonymousRepositoryClient(type, Files.createDirectories(temporaryFolder).toFile()));
+ }
+ }
+
+ @Nested
+ class WithAnonymousAccessForRepository {
+
+ @BeforeEach
+ void grantAnonymousAccessToRepo() {
+ ScmTypes.availableScmTypes().forEach(type -> TestData.createUserPermission(USER_ANONYMOUS, WRITE, type));
+ }
+
+ @ParameterizedTest
+ @ArgumentsSource(ScmTypes.class)
+ void shouldGrantAnonymousAccessToRepository(String type) {
+ assertEquals(200, RestAssured.given()
+ .when()
+ .get(getDefaultRepositoryUrl(type))
+ .statusCode());
+ }
+
+ @ParameterizedTest
+ @ArgumentsSource(ScmTypes.class)
+ void shouldCloneRepository(String type, @TempDir Path temporaryFolder) throws IOException {
+ RepositoryClient client = RepositoryUtil.createAnonymousRepositoryClient(type, Files.createDirectories(temporaryFolder).toFile());
+ assertEquals(1, Objects.requireNonNull(client.getWorkingCopy().list()).length);
+ }
+ }
+
+ @AfterAll
+ void disableAnonymousAccess() {
+ setAnonymousAccess(AnonymousMode.OFF);
+ }
+ }
+
+ private static void setAnonymousAccess(AnonymousMode anonymousMode) {
RestUtil.given("application/vnd.scmm-config+json;v=2")
- .body(createConfig(anonymousAccessEnabled))
+ .body(createConfig(anonymousMode))
.when()
.put(RestUtil.REST_BASE_URL.toASCIIString() + "config")
@@ -157,12 +229,12 @@ class AnonymousAccessITCase {
.statusCode(HttpServletResponse.SC_NO_CONTENT);
}
- private static String createConfig(boolean anonymousAccessEnabled) {
+ private static String createConfig(AnonymousMode anonymousMode) {
JsonArray emptyArray = Json.createBuilderFactory(emptyMap()).createArrayBuilder().build();
return JSON_BUILDER
.add("adminGroups", emptyArray)
.add("adminUsers", emptyArray)
- .add("anonymousAccessEnabled", anonymousAccessEnabled)
+ .add("anonymousMode", anonymousMode.toString())
.add("baseUrl", "https://next-scm.cloudogu.com/scm")
.add("dateFormat", "YYYY-MM-DD HH:mm:ss")
.add("disableGroupingGrid", false)
diff --git a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java
index 6c5efe722c..61c3013f36 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/org/eclipse/jgit/transport/ScmTransportProtocol.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package org.eclipse.jgit.transport;
//~--- non-JDK imports --------------------------------------------------------
@@ -29,200 +29,106 @@ package org.eclipse.jgit.transport;
import com.google.common.collect.ImmutableSet;
import com.google.inject.Inject;
import com.google.inject.Provider;
-
import org.eclipse.jgit.errors.NoRemoteRepositoryException;
-import org.eclipse.jgit.errors.NotSupportedException;
import org.eclipse.jgit.errors.TransportException;
import org.eclipse.jgit.internal.JGitText;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.lib.RepositoryCache;
-
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.spi.HookEventFacade;
import sonia.scm.web.CollectingPackParserListener;
import sonia.scm.web.GitReceiveHook;
-//~--- JDK imports ------------------------------------------------------------
-
import java.io.File;
-
import java.util.Set;
+//~--- JDK imports ------------------------------------------------------------
+
/**
- *
* @author Sebastian Sdorra
*/
-public class ScmTransportProtocol extends TransportProtocol
-{
+public class ScmTransportProtocol extends TransportProtocol {
- /** Field description */
public static final String NAME = "scm";
-
- /** Field description */
private static final Set SCHEMES = ImmutableSet.of(NAME);
- //~--- constructors ---------------------------------------------------------
+ private Provider converterFactory;
+ private Provider hookEventFacadeProvider;
+ private Provider repositoryHandlerProvider;
- /**
- * Constructs ...
- *
- */
- public ScmTransportProtocol() {}
+ public ScmTransportProtocol() {
+ }
- /**
- * Constructs ...
- *
- *
- *
- * @param hookEventFacadeProvider
- *
- * @param repositoryHandlerProvider
- */
@Inject
public ScmTransportProtocol(
+ Provider converterFactory,
Provider hookEventFacadeProvider,
- Provider repositoryHandlerProvider)
- {
+ Provider repositoryHandlerProvider) {
+ this.converterFactory = converterFactory;
this.hookEventFacadeProvider = hookEventFacadeProvider;
this.repositoryHandlerProvider = repositoryHandlerProvider;
}
- //~--- methods --------------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @param uri
- * @param local
- * @param remoteName
- *
- * @return
- */
@Override
- public boolean canHandle(URIish uri, Repository local, String remoteName)
- {
- if ((uri.getPath() == null) || (uri.getPort() > 0)
- || (uri.getUser() != null) || (uri.getPass() != null)
- || (uri.getHost() != null)
- || ((uri.getScheme() != null) &&!getSchemes().contains(uri.getScheme())))
- {
- return false;
- }
-
- return true;
+ public boolean canHandle(URIish uri, Repository local, String remoteName) {
+ return (uri.getPath() != null) && (uri.getPort() <= 0)
+ && (uri.getUser() == null) && (uri.getPass() == null)
+ && (uri.getHost() == null)
+ && ((uri.getScheme() == null) || getSchemes().contains(uri.getScheme()));
}
- /**
- * Method description
- *
- *
- * @param uri
- * @param local
- * @param remoteName
- *
- * @return
- *
- * @throws NotSupportedException
- * @throws TransportException
- */
@Override
- public Transport open(URIish uri, Repository local, String remoteName)
- throws TransportException
- {
+ public Transport open(URIish uri, Repository local, String remoteName) throws TransportException {
File localDirectory = local.getDirectory();
File path = local.getFS().resolve(localDirectory, uri.getPath());
File gitDir = RepositoryCache.FileKey.resolve(path, local.getFS());
- if (gitDir == null)
- {
+ if (gitDir == null) {
throw new NoRemoteRepositoryException(uri, JGitText.get().notFound);
}
- //J-
return new TransportLocalWithHooks(
+ converterFactory.get(),
hookEventFacadeProvider.get(),
repositoryHandlerProvider.get(),
local, uri, gitDir
);
- //J+
}
- //~--- get methods ----------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public String getName()
- {
+ public String getName() {
return NAME;
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public Set getSchemes()
- {
+ public Set getSchemes() {
return SCHEMES;
}
- //~--- inner classes --------------------------------------------------------
+ private static class TransportLocalWithHooks extends TransportLocal {
- /**
- * Class description
- *
- *
- * @version Enter version here..., 13/05/19
- * @author Enter your name here...
- */
- private static class TransportLocalWithHooks extends TransportLocal
- {
+ private final GitChangesetConverterFactory converterFactory;
+ private final GitRepositoryHandler handler;
+ private final HookEventFacade hookEventFacade;
- /**
- * Constructs ...
- *
- *
- *
- * @param hookEventFacade
- * @param handler
- * @param local
- * @param uri
- * @param gitDir
- */
- public TransportLocalWithHooks(HookEventFacade hookEventFacade,
- GitRepositoryHandler handler, Repository local, URIish uri, File gitDir)
- {
+ public TransportLocalWithHooks(
+ GitChangesetConverterFactory converterFactory,
+ HookEventFacade hookEventFacade,
+ GitRepositoryHandler handler,
+ Repository local, URIish uri, File gitDir) {
super(local, uri, gitDir);
+ this.converterFactory = converterFactory;
this.hookEventFacade = hookEventFacade;
this.handler = handler;
}
- //~--- methods ------------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @param dst
- *
- * @return
- */
@Override
- ReceivePack createReceivePack(Repository dst)
- {
+ ReceivePack createReceivePack(Repository dst) {
ReceivePack pack = new ReceivePack(dst);
- if ((hookEventFacade != null) && (handler != null))
- {
- GitReceiveHook hook = new GitReceiveHook(hookEventFacade, handler);
+ if ((hookEventFacade != null) && (handler != null) && (converterFactory != null)) {
+ GitReceiveHook hook = new GitReceiveHook(converterFactory, hookEventFacade, handler);
pack.setPreReceiveHook(hook);
pack.setPostReceiveHook(hook);
@@ -232,22 +138,6 @@ public class ScmTransportProtocol extends TransportProtocol
return pack;
}
-
- //~--- fields -------------------------------------------------------------
-
- /** Field description */
- private GitRepositoryHandler handler;
-
- /** Field description */
- private HookEventFacade hookEventFacade;
}
-
- //~--- fields ---------------------------------------------------------------
-
- /** Field description */
- private Provider hookEventFacadeProvider;
-
- /** Field description */
- private Provider repositoryHandlerProvider;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/BaseReceivePackFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/BaseReceivePackFactory.java
index 9b18991d1a..028e91009d 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/BaseReceivePackFactory.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/BaseReceivePackFactory.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.protocolcommand.git;
import org.eclipse.jgit.lib.Repository;
@@ -29,6 +29,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.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.spi.HookEventFacade;
import sonia.scm.web.CollectingPackParserListener;
@@ -39,9 +40,9 @@ public abstract class BaseReceivePackFactory implements ReceivePackFactory
private final GitRepositoryHandler handler;
private final GitReceiveHook hook;
- protected BaseReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
+ protected BaseReceivePackFactory(GitChangesetConverterFactory converterFactory, GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
this.handler = handler;
- this.hook = new GitReceiveHook(hookEventFacade, handler);
+ this.hook = new GitReceiveHook(converterFactory, hookEventFacade, handler);
}
@Override
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmReceivePackFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmReceivePackFactory.java
index f15ccfcf99..a999a57d4d 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmReceivePackFactory.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/protocolcommand/git/ScmReceivePackFactory.java
@@ -21,21 +21,22 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
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.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.spi.HookEventFacade;
public class ScmReceivePackFactory extends BaseReceivePackFactory {
@Inject
- public ScmReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
- super(handler, hookEventFacade);
+ public ScmReceivePackFactory(GitChangesetConverterFactory converterFactory, GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
+ super(converterFactory, handler, hookEventFacade);
}
@Override
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java
index 0f08f54aaa..1262353e1d 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverter.java
@@ -26,6 +26,7 @@ package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
+import com.google.common.base.Strings;
import com.google.common.collect.Lists;
import com.google.common.collect.Multimap;
import org.eclipse.jgit.lib.ObjectId;
@@ -33,138 +34,55 @@ import org.eclipse.jgit.lib.PersonIdent;
import org.eclipse.jgit.revwalk.RevCommit;
import org.eclipse.jgit.revwalk.RevWalk;
import org.eclipse.jgit.treewalk.TreeWalk;
-import org.slf4j.Logger;
-import org.slf4j.LoggerFactory;
+import org.eclipse.jgit.util.RawParseUtils;
+import sonia.scm.security.GPG;
+import sonia.scm.security.PublicKey;
import sonia.scm.util.Util;
+import java.io.ByteArrayOutputStream;
import java.io.Closeable;
import java.io.IOException;
import java.util.ArrayList;
+import java.util.Arrays;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
+import java.util.Optional;
//~--- JDK imports ------------------------------------------------------------
/**
- *
* @author Sebastian Sdorra
*/
-public class GitChangesetConverter implements Closeable
-{
+public class GitChangesetConverter implements Closeable {
- /**
- * the logger for GitChangesetConverter
- */
- private static final Logger logger =
- LoggerFactory.getLogger(GitChangesetConverter.class);
-
- //~--- constructors ---------------------------------------------------------
-
- /**
- * Constructs ...
- *
- *
- * @param repository
- */
- public GitChangesetConverter(org.eclipse.jgit.lib.Repository repository)
- {
- this(repository, null);
- }
-
- /**
- * Constructs ...
- *
- *
- * @param repository
- * @param revWalk
- */
- public GitChangesetConverter(org.eclipse.jgit.lib.Repository repository,
- RevWalk revWalk)
- {
- this.repository = repository;
-
- if (revWalk != null)
- {
- this.revWalk = revWalk;
-
- }
- else
- {
- this.revWalk = new RevWalk(repository);
- }
+ private final GPG gpg;
+ private final Multimap tags;
+ private final TreeWalk treeWalk;
+ public GitChangesetConverter(GPG gpg, org.eclipse.jgit.lib.Repository repository, RevWalk revWalk) {
+ this.gpg = gpg;
this.tags = GitUtil.createTagMap(repository, revWalk);
- treeWalk = new TreeWalk(repository);
+ this.treeWalk = new TreeWalk(repository);
}
- //~--- methods --------------------------------------------------------------
-
- /**
- * Method description
- *
- */
- @Override
- public void close()
- {
- GitUtil.release(treeWalk);
- }
-
- /**
- * Method description
- *
- *
- * @param commit
- *
- * @return
- *
- * @throws IOException
- */
- public Changeset createChangeset(RevCommit commit)
- {
+ public Changeset createChangeset(RevCommit commit) {
return createChangeset(commit, Collections.emptyList());
}
- /**
- * Method description
- *
- *
- * @param commit
- * @param branch
- *
- * @return
- *
- * @throws IOException
- */
- public Changeset createChangeset(RevCommit commit, String branch)
- {
+ public Changeset createChangeset(RevCommit commit, String branch) {
return createChangeset(commit, Lists.newArrayList(branch));
}
- /**
- * Method description
- *
- *
- *
- * @param commit
- * @param branches
- *
- * @return
- *
- * @throws IOException
- */
- public Changeset createChangeset(RevCommit commit, List branches)
- {
+ public Changeset createChangeset(RevCommit commit, List branches) {
String id = commit.getId().name();
List parentList = null;
RevCommit[] parents = commit.getParents();
- if (Util.isNotEmpty(parents))
- {
- parentList = new ArrayList();
+ if (Util.isNotEmpty(parents)) {
+ parentList = new ArrayList<>();
- for (RevCommit parent : parents)
- {
+ for (RevCommit parent : parents) {
parentList.add(parent.getId().name());
}
}
@@ -175,8 +93,7 @@ public class GitChangesetConverter implements Closeable
Person author = createPersonFor(authorIndent);
String message = commit.getFullMessage();
- if (message != null)
- {
+ if (message != null) {
message = message.trim();
}
@@ -185,41 +102,83 @@ public class GitChangesetConverter implements Closeable
changeset.addContributor(new Contributor("Committed-by", createPersonFor(committerIdent)));
}
- if (parentList != null)
- {
+ if (parentList != null) {
changeset.setParents(parentList);
}
Collection tagCollection = tags.get(commit.getId());
- if (Util.isNotEmpty(tagCollection))
- {
-
+ if (Util.isNotEmpty(tagCollection)) {
// create a copy of the tag collection to reduce memory on caching
changeset.getTags().addAll(Lists.newArrayList(tagCollection));
}
changeset.setBranches(branches);
+ Signature signature = createSignature(commit);
+ if (signature != null) {
+ changeset.addSignature(signature);
+ }
+
return changeset;
}
+ private static final byte[] GPG_HEADER = {'g', 'p', 'g', 's', 'i', 'g'};
+
+ private Signature createSignature(RevCommit commit) {
+ byte[] raw = commit.getRawBuffer();
+
+ int start = RawParseUtils.headerStart(GPG_HEADER, raw, 0);
+ if (start < 0) {
+ return null;
+ }
+
+ int end = RawParseUtils.headerEnd(raw, start);
+ byte[] signature = Arrays.copyOfRange(raw, start, end);
+
+ String publicKeyId = gpg.findPublicKeyId(signature);
+ if (Strings.isNullOrEmpty(publicKeyId)) {
+ // key not found
+ return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet());
+ }
+
+ Optional publicKeyById = gpg.findPublicKey(publicKeyId);
+ if (!publicKeyById.isPresent()) {
+ // key not found
+ return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet());
+ }
+
+ PublicKey publicKey = publicKeyById.get();
+
+ ByteArrayOutputStream baos = new ByteArrayOutputStream();
+ try {
+ byte[] headerPrefix = Arrays.copyOfRange(raw, 0, start - GPG_HEADER.length - 1);
+ baos.write(headerPrefix);
+
+ byte[] headerSuffix = Arrays.copyOfRange(raw, end + 1, raw.length);
+ baos.write(headerSuffix);
+ } catch (IOException ex) {
+ // this will never happen, because we are writing into memory
+ throw new IllegalStateException("failed to write into memory", ex);
+ }
+
+ boolean verified = publicKey.verify(baos.toByteArray(), signature);
+ return new Signature(
+ publicKeyId,
+ "gpg",
+ verified ? SignatureStatus.VERIFIED : SignatureStatus.INVALID,
+ publicKey.getOwner().orElse(null),
+ publicKey.getContacts()
+ );
+ }
+
public Person createPersonFor(PersonIdent personIndent) {
return new Person(personIndent.getName(), personIndent.getEmailAddress());
}
+ @Override
+ public void close() {
+ GitUtil.release(treeWalk);
+ }
- //~--- fields ---------------------------------------------------------------
-
- /** Field description */
- private org.eclipse.jgit.lib.Repository repository;
-
- /** Field description */
- private RevWalk revWalk;
-
- /** Field description */
- private Multimap tags;
-
- /** Field description */
- private TreeWalk treeWalk;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java
new file mode 100644
index 0000000000..4f4389fa2e
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitChangesetConverterFactory.java
@@ -0,0 +1,50 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.repository;
+
+import org.eclipse.jgit.lib.Repository;
+import org.eclipse.jgit.revwalk.RevWalk;
+import sonia.scm.security.GPG;
+
+import javax.inject.Inject;
+
+public class GitChangesetConverterFactory {
+
+ private final GPG gpg;
+
+ @Inject
+ public GitChangesetConverterFactory(GPG gpg) {
+ this.gpg = gpg;
+ }
+
+ public GitChangesetConverter create(Repository repository) {
+ return new GitChangesetConverter(gpg, repository, new RevWalk(repository));
+ }
+
+ public GitChangesetConverter create(Repository repository, RevWalk revWalk) {
+ return new GitChangesetConverter(gpg, repository, revWalk);
+ }
+
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java
index 3071f590bc..5d7de5ce27 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/GitHookChangesetCollector.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
@@ -72,9 +72,10 @@ public class GitHookChangesetCollector
* @param rpack
* @param receiveCommands
*/
- public GitHookChangesetCollector(ReceivePack rpack,
+ public GitHookChangesetCollector(GitChangesetConverterFactory converterFactory, ReceivePack rpack,
List receiveCommands)
{
+ this.converterFactory = converterFactory;
this.rpack = rpack;
this.receiveCommands = receiveCommands;
this.listener = CollectingPackParserListener.get(rpack);
@@ -100,14 +101,14 @@ public class GitHookChangesetCollector
try
{
walk = rpack.getRevWalk();
- converter = new GitChangesetConverter(repository, walk);
+ converter = converterFactory.create(repository, walk);
for (ReceiveCommand rc : receiveCommands)
{
String ref = rc.getRefName();
-
+
logger.trace("handle receive command, type={}, ref={}, result={}", rc.getType(), ref, rc.getResult());
-
+
if (rc.getType() == ReceiveCommand.Type.DELETE)
{
logger.debug("skip delete of ref {}", ref);
@@ -130,7 +131,7 @@ public class GitHookChangesetCollector
builder.append(rc.getType()).append(", ref=");
builder.append(rc.getRefName()).append(", result=");
builder.append(rc.getResult());
-
+
logger.error(builder.toString(), ex);
}
}
@@ -222,5 +223,6 @@ public class GitHookChangesetCollector
private final List receiveCommands;
+ private final GitChangesetConverterFactory converterFactory;
private final ReceivePack rpack;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java
new file mode 100644
index 0000000000..423cd2aca8
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSigner.java
@@ -0,0 +1,61 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.repository;
+
+import org.eclipse.jgit.api.errors.CanceledException;
+import org.eclipse.jgit.lib.CommitBuilder;
+import org.eclipse.jgit.lib.GpgSignature;
+import org.eclipse.jgit.lib.GpgSigner;
+import org.eclipse.jgit.lib.PersonIdent;
+import org.eclipse.jgit.transport.CredentialsProvider;
+import sonia.scm.security.GPG;
+
+import javax.inject.Inject;
+import java.io.UnsupportedEncodingException;
+
+public class ScmGpgSigner extends GpgSigner {
+
+ private final GPG gpg;
+
+ @Inject
+ public ScmGpgSigner(GPG gpg) {
+ this.gpg = gpg;
+ }
+
+ @Override
+ public void sign(CommitBuilder commitBuilder, String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException {
+ try {
+ final byte[] signature = this.gpg.getPrivateKey().sign(commitBuilder.build());
+ commitBuilder.setGpgSignature(new GpgSignature(signature));
+ } catch (UnsupportedEncodingException e) {
+ throw new IllegalStateException(e);
+ }
+ }
+
+ @Override
+ public boolean canLocateSigningKey(String keyId, PersonIdent personIdent, CredentialsProvider credentialsProvider) throws CanceledException {
+ return true;
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java
new file mode 100644
index 0000000000..144601275d
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/ScmGpgSignerInitializer.java
@@ -0,0 +1,53 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.repository;
+
+import org.eclipse.jgit.lib.GpgSigner;
+import sonia.scm.plugin.Extension;
+
+import javax.inject.Inject;
+import javax.servlet.ServletContextEvent;
+import javax.servlet.ServletContextListener;
+
+@Extension
+public class ScmGpgSignerInitializer implements ServletContextListener {
+
+ private final ScmGpgSigner scmGpgSigner;
+
+ @Inject
+ public ScmGpgSignerInitializer(ScmGpgSigner scmGpgSigner) {
+ this.scmGpgSigner = scmGpgSigner;
+ }
+
+ @Override
+ public void contextInitialized(ServletContextEvent servletContextEvent) {
+ GpgSigner.setDefault(scmGpgSigner);
+ }
+
+ @Override
+ public void contextDestroyed(ServletContextEvent servletContextEvent) {
+ // Do nothing
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitReceiveHookMergeDetectionProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitReceiveHookMergeDetectionProvider.java
index 03a5b59837..bac6eb6348 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitReceiveHookMergeDetectionProvider.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/api/GitReceiveHookMergeDetectionProvider.java
@@ -28,6 +28,7 @@ import org.eclipse.jgit.lib.AnyObjectId;
import org.eclipse.jgit.lib.ObjectId;
import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.ReceiveCommand;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.spi.GitLogComputer;
import sonia.scm.repository.spi.HookMergeDetectionProvider;
@@ -39,11 +40,13 @@ public class GitReceiveHookMergeDetectionProvider implements HookMergeDetectionP
private final Repository repository;
private final String repositoryId;
private final List receiveCommands;
+ private final GitChangesetConverterFactory converterFactory;
- public GitReceiveHookMergeDetectionProvider(Repository repository, String repositoryId, List receiveCommands) {
+ public GitReceiveHookMergeDetectionProvider(Repository repository, String repositoryId, List receiveCommands, GitChangesetConverterFactory converterFactory) {
this.repository = repository;
this.repositoryId = repositoryId;
this.receiveCommands = receiveCommands;
+ this.converterFactory = converterFactory;
}
@Override
@@ -53,7 +56,7 @@ public class GitReceiveHookMergeDetectionProvider implements HookMergeDetectionP
request.setAncestorChangeset(findRelevantRevisionForBranchIfToBeUpdated(target));
request.setPagingLimit(1);
- return new GitLogComputer(repositoryId, repository).compute(request).getTotal() == 0;
+ return new GitLogComputer(repositoryId, repository, converterFactory).compute(request).getTotal() == 0;
}
private String findRelevantRevisionForBranchIfToBeUpdated(String branch) {
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java
index 9e13e82971..c083123242 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitCommand.java
@@ -63,11 +63,9 @@ import static sonia.scm.repository.GitUtil.getBranchIdOrCurrentHead;
//~--- JDK imports ------------------------------------------------------------
/**
- *
* @author Sebastian Sdorra
*/
-class AbstractGitCommand
-{
+class AbstractGitCommand {
/**
* the logger for AbstractGitCommand
@@ -77,11 +75,9 @@ class AbstractGitCommand
/**
* Constructs ...
*
- * @param context
- *
+ * @param context
*/
- AbstractGitCommand(GitContext context)
- {
+ AbstractGitCommand(GitContext context) {
this.repository = context.getRepository();
this.context = context;
}
@@ -91,19 +87,16 @@ class AbstractGitCommand
/**
* Method description
*
- *
* @return
- *
* @throws IOException
*/
- Repository open() throws IOException
- {
+ Repository open() throws IOException {
return context.open();
}
ObjectId getCommitOrDefault(Repository gitRepository, String requestedCommit) throws IOException {
ObjectId commit;
- if ( Strings.isNullOrEmpty(requestedCommit) ) {
+ if (Strings.isNullOrEmpty(requestedCommit)) {
commit = getDefaultBranch(gitRepository);
} else {
commit = gitRepository.resolve(requestedCommit);
@@ -121,7 +114,7 @@ class AbstractGitCommand
}
Ref getBranchOrDefault(Repository gitRepository, String requestedBranch) throws IOException {
- if ( Strings.isNullOrEmpty(requestedBranch) ) {
+ if (Strings.isNullOrEmpty(requestedBranch)) {
String defaultBranchName = context.getConfig().getDefaultBranch();
return getBranchIdOrCurrentHead(gitRepository, defaultBranchName);
} else {
@@ -220,7 +213,7 @@ class AbstractGitCommand
}
}
- Optional doCommit(String message, Person author) {
+ Optional doCommit(String message, Person author, boolean sign) {
Person authorToUse = determineAuthor(author);
try {
Status status = clone.status().call();
@@ -229,6 +222,8 @@ class AbstractGitCommand
.setAuthor(authorToUse.getName(), authorToUse.getMail())
.setCommitter("SCM-Manager", "noreply@scm-manager.org")
.setMessage(message)
+ .setSign(sign)
+ .setSigningKey(sign ? "SCM-MANAGER-DEFAULT-KEY" : null)
.call());
} else {
return empty();
@@ -288,9 +283,13 @@ class AbstractGitCommand
//~--- fields ---------------------------------------------------------------
- /** Field description */
+ /**
+ * Field description
+ */
protected GitContext context;
- /** Field description */
+ /**
+ * Field description
+ */
protected sonia.scm.repository.Repository repository;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java
index 1b03a5a55e..94b1451d68 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/AbstractGitIncomingOutgoingCommand.java
@@ -36,6 +36,7 @@ import org.eclipse.jgit.revwalk.RevWalk;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.GitChangesetConverter;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
@@ -58,18 +59,10 @@ public abstract class AbstractGitIncomingOutgoingCommand
/** Field description */
private static final String REMOTE_REF_PREFIX = "refs/remote/scm/%s/";
- //~--- constructors ---------------------------------------------------------
-
- /**
- * Constructs ...
- *
- * @param handler
- * @param context
- */
- AbstractGitIncomingOutgoingCommand(GitRepositoryHandler handler, GitContext context)
- {
+ AbstractGitIncomingOutgoingCommand(GitContext context, GitRepositoryHandler handler, GitChangesetConverterFactory converterFactory) {
super(context);
this.handler = handler;
+ this.converterFactory = converterFactory;
}
//~--- methods --------------------------------------------------------------
@@ -132,7 +125,7 @@ public abstract class AbstractGitIncomingOutgoingCommand
try
{
walk = new RevWalk(git.getRepository());
- converter = new GitChangesetConverter(git.getRepository(), walk);
+ converter = converterFactory.create(git.getRepository(), walk);
org.eclipse.jgit.api.LogCommand log = git.log();
@@ -203,4 +196,5 @@ public abstract class AbstractGitIncomingOutgoingCommand
/** Field description */
private GitRepositoryHandler handler;
+ private final GitChangesetConverterFactory converterFactory;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBlameCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBlameCommand.java
index 5c1f074a90..d99343b350 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBlameCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBlameCommand.java
@@ -41,6 +41,7 @@ import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Person;
+import javax.inject.Inject;
import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
@@ -64,6 +65,7 @@ public class GitBlameCommand extends AbstractGitCommand implements BlameCommand
//~--- constructors ---------------------------------------------------------
+ @Inject
public GitBlameCommand(GitContext context)
{
super(context);
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java
index eb372ff063..43bfb68578 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchCommand.java
@@ -43,6 +43,7 @@ import sonia.scm.repository.api.HookContext;
import sonia.scm.repository.api.HookContextFactory;
import sonia.scm.repository.api.HookFeature;
+import javax.inject.Inject;
import java.io.IOException;
import java.util.List;
import java.util.Set;
@@ -57,6 +58,7 @@ public class GitBranchCommand extends AbstractGitCommand implements BranchComman
private final HookContextFactory hookContextFactory;
private final ScmEventBus eventBus;
+ @Inject
GitBranchCommand(GitContext context, HookContextFactory hookContextFactory, ScmEventBus eventBus) {
super(context);
this.hookContextFactory = hookContextFactory;
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java
index cd7892461b..d026affd8b 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBranchesCommand.java
@@ -38,6 +38,7 @@ import sonia.scm.repository.Branch;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
+import javax.inject.Inject;
import java.io.IOException;
import java.util.List;
import java.util.Optional;
@@ -53,6 +54,7 @@ public class GitBranchesCommand extends AbstractGitCommand implements BranchesCo
private static final Logger LOG = LoggerFactory.getLogger(GitBranchesCommand.class);
+ @Inject
public GitBranchesCommand(GitContext context)
{
super(context);
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java
index ab5a7d33b4..05792b9707 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitBrowseCommand.java
@@ -57,6 +57,7 @@ import sonia.scm.store.BlobStore;
import sonia.scm.util.Util;
import sonia.scm.web.lfs.LfsBlobStoreFactory;
+import javax.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.ArrayList;
@@ -111,6 +112,11 @@ public class GitBrowseCommand extends AbstractGitCommand
private int resultCount = 0;
+ @Inject
+ public GitBrowseCommand(GitContext context, LfsBlobStoreFactory lfsBlobStoreFactory, SyncAsyncExecutorProvider executorProvider) {
+ this(context, lfsBlobStoreFactory, executorProvider.createExecutorWithDefaultTimeout());
+ }
+
public GitBrowseCommand(GitContext context, LfsBlobStoreFactory lfsBlobStoreFactory, SyncAsyncExecutor executor) {
super(context);
this.lfsBlobStoreFactory = lfsBlobStoreFactory;
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java
index 4629e4bce2..8c2e3b44d5 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitCatCommand.java
@@ -44,6 +44,7 @@ import sonia.scm.util.IOUtil;
import sonia.scm.util.Util;
import sonia.scm.web.lfs.LfsBlobStoreFactory;
+import javax.inject.Inject;
import java.io.Closeable;
import java.io.FilterInputStream;
import java.io.IOException;
@@ -61,6 +62,7 @@ public class GitCatCommand extends AbstractGitCommand implements CatCommand {
private final LfsBlobStoreFactory lfsBlobStoreFactory;
+ @Inject
public GitCatCommand(GitContext context, LfsBlobStoreFactory lfsBlobStoreFactory) {
super(context);
this.lfsBlobStoreFactory = lfsBlobStoreFactory;
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java
new file mode 100644
index 0000000000..04bb37bed0
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitContextFactory.java
@@ -0,0 +1,48 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.repository.spi;
+
+import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider;
+import sonia.scm.repository.GitRepositoryHandler;
+import sonia.scm.repository.Repository;
+
+import javax.inject.Inject;
+
+class GitContextFactory {
+
+ private final GitRepositoryHandler handler;
+ private final GitRepositoryConfigStoreProvider storeProvider;
+
+ @Inject
+ GitContextFactory(GitRepositoryHandler handler, GitRepositoryConfigStoreProvider storeProvider) {
+ this.handler = handler;
+ this.storeProvider = storeProvider;
+ }
+
+ GitContext create(Repository repository) {
+ return new GitContext(handler.getDirectory(repository.getId()), repository, storeProvider);
+ }
+
+}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java
index adb7a7bd0e..c0ed1a53bc 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffCommand.java
@@ -29,6 +29,7 @@ import org.eclipse.jgit.diff.DiffFormatter;
import org.eclipse.jgit.util.QuotedString;
import sonia.scm.repository.api.DiffCommandBuilder;
+import javax.inject.Inject;
import java.io.BufferedOutputStream;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
@@ -41,6 +42,7 @@ import static java.nio.charset.StandardCharsets.UTF_8;
*/
public class GitDiffCommand extends AbstractGitCommand implements DiffCommand {
+ @Inject
GitDiffCommand(GitContext context) {
super(context);
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java
index fed865c576..e55d8badae 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitDiffResultCommand.java
@@ -32,6 +32,7 @@ import sonia.scm.repository.api.DiffFile;
import sonia.scm.repository.api.DiffResult;
import sonia.scm.repository.api.Hunk;
+import javax.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Iterator;
@@ -39,6 +40,7 @@ import java.util.stream.Collectors;
public class GitDiffResultCommand extends AbstractGitCommand implements DiffResultCommand {
+ @Inject
GitDiffResultCommand(GitContext context) {
super(context);
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java
index 3280e2c5c4..1dbe652371 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookChangesetProvider.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
@@ -29,6 +29,7 @@ package sonia.scm.repository.spi;
import org.eclipse.jgit.transport.ReceiveCommand;
import org.eclipse.jgit.transport.ReceivePack;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitHookChangesetCollector;
//~--- JDK imports ------------------------------------------------------------
@@ -39,56 +40,27 @@ import java.util.List;
*
* @author Sebastian Sdorra
*/
-public class GitHookChangesetProvider implements HookChangesetProvider
-{
+public class GitHookChangesetProvider implements HookChangesetProvider {
- /**
- * Constructs ...
- *
- *
- * @param receivePack
- * @param receiveCommands
- */
- public GitHookChangesetProvider(ReceivePack receivePack,
- List receiveCommands)
- {
+ private final GitChangesetConverterFactory converterFactory;
+ private final ReceivePack receivePack;
+ private final List receiveCommands;
+
+ private HookChangesetResponse response;
+
+ public GitHookChangesetProvider(GitChangesetConverterFactory converterFactory, ReceivePack receivePack,
+ List receiveCommands) {
+ this.converterFactory = converterFactory;
this.receivePack = receivePack;
this.receiveCommands = receiveCommands;
}
- //~--- methods --------------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @param request
- *
- * @return
- */
@Override
- public synchronized HookChangesetResponse handleRequest(
- HookChangesetRequest request)
- {
- if (response == null)
- {
- GitHookChangesetCollector collector =
- new GitHookChangesetCollector(receivePack, receiveCommands);
-
+ public synchronized HookChangesetResponse handleRequest(HookChangesetRequest request) {
+ if (response == null) {
+ GitHookChangesetCollector collector = new GitHookChangesetCollector(converterFactory, receivePack, receiveCommands);
response = new HookChangesetResponse(collector.collectChangesets());
}
-
return response;
}
-
- //~--- fields ---------------------------------------------------------------
-
- /** Field description */
- private List receiveCommands;
-
- /** Field description */
- private ReceivePack receivePack;
-
- /** Field description */
- private HookChangesetResponse response;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookContextProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookContextProvider.java
index bc8d633add..13a11007a2 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookContextProvider.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitHookContextProvider.java
@@ -30,6 +30,7 @@ import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.transport.ReceiveCommand;
import org.eclipse.jgit.transport.ReceivePack;
import sonia.scm.repository.api.GitHookBranchProvider;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.api.GitHookMessageProvider;
import sonia.scm.repository.api.GitHookTagProvider;
import sonia.scm.repository.api.GitReceiveHookMergeDetectionProvider;
@@ -62,14 +63,16 @@ public class GitHookContextProvider extends HookContextProvider
//~--- constructors ---------------------------------------------------------
+ private final GitChangesetConverterFactory converterFactory;
+
/**
* Constructs a new instance
* @param receivePack git receive pack
* @param receiveCommands received commands
*/
public GitHookContextProvider(
- ReceivePack receivePack,
- List receiveCommands,
+ GitChangesetConverterFactory converterFactory, ReceivePack receivePack,
+ List receiveCommands,
Repository repository,
String repositoryId
) {
@@ -77,8 +80,9 @@ public class GitHookContextProvider extends HookContextProvider
this.receiveCommands = receiveCommands;
this.repository = repository;
this.repositoryId = repositoryId;
- this.changesetProvider = new GitHookChangesetProvider(receivePack,
+ this.changesetProvider = new GitHookChangesetProvider(converterFactory, receivePack,
receiveCommands);
+ this.converterFactory = converterFactory;
}
//~--- methods --------------------------------------------------------------
@@ -110,7 +114,7 @@ public class GitHookContextProvider extends HookContextProvider
@Override
public HookMergeDetectionProvider getMergeDetectionProvider() {
- return new GitReceiveHookMergeDetectionProvider(repository, repositoryId, receiveCommands);
+ return new GitReceiveHookMergeDetectionProvider(repository, repositoryId, receiveCommands, converterFactory);
}
@Override
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitIncomingCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitIncomingCommand.java
index f6e818bcdb..205d9e9e3f 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitIncomingCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitIncomingCommand.java
@@ -29,8 +29,10 @@ package sonia.scm.repository.spi;
import org.eclipse.jgit.api.LogCommand;
import org.eclipse.jgit.lib.ObjectId;
import sonia.scm.repository.ChangesetPagingResult;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
+import javax.inject.Inject;
import java.io.IOException;
//~--- JDK imports ------------------------------------------------------------
@@ -40,18 +42,11 @@ import java.io.IOException;
* @author Sebastian Sdorra
*/
public class GitIncomingCommand extends AbstractGitIncomingOutgoingCommand
- implements IncomingCommand
-{
+ implements IncomingCommand {
- /**
- * Constructs ...
- *
- * @param handler
- * @param context
- */
- GitIncomingCommand(GitRepositoryHandler handler, GitContext context)
- {
- super(handler, context);
+ @Inject
+ GitIncomingCommand(GitContext context, GitRepositoryHandler handler, GitChangesetConverterFactory converterFactory) {
+ super(context, handler, converterFactory);
}
//~--- get methods ----------------------------------------------------------
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java
index 340df65c16..e0f0b2868b 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogCommand.java
@@ -36,10 +36,12 @@ import org.slf4j.LoggerFactory;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.GitChangesetConverter;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.util.IOUtil;
+import javax.inject.Inject;
import java.io.IOException;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
@@ -60,6 +62,7 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
private static final Logger logger =
LoggerFactory.getLogger(GitLogCommand.class);
public static final String REVISION = "Revision";
+ private final GitChangesetConverterFactory converterFactory;
//~--- constructors ---------------------------------------------------------
@@ -70,9 +73,11 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
* @param context
*
*/
- GitLogCommand(GitContext context)
+ @Inject
+ GitLogCommand(GitContext context, GitChangesetConverterFactory converterFactory)
{
super(context);
+ this.converterFactory = converterFactory;
}
//~--- get methods ----------------------------------------------------------
@@ -110,7 +115,7 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
if (commit != null)
{
- converter = new GitChangesetConverter(gr, revWalk);
+ converter = converterFactory.create(gr, revWalk);
if (isBranchRequested(request)) {
String branch = request.getBranch();
@@ -177,7 +182,7 @@ public class GitLogCommand extends AbstractGitCommand implements LogCommand
if (Strings.isNullOrEmpty(request.getBranch())) {
request.setBranch(context.getConfig().getDefaultBranch());
}
- return new GitLogComputer(this.repository.getId(), gitRepository).compute(request);
+ return new GitLogComputer(this.repository.getId(), gitRepository, converterFactory).compute(request);
} catch (IOException e) {
throw new InternalRepositoryException(repository, "could not create change log", e);
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogComputer.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogComputer.java
index eae8ea5851..b33460fb37 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogComputer.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitLogComputer.java
@@ -42,6 +42,7 @@ import sonia.scm.NotFoundException;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.ChangesetPagingResult;
import sonia.scm.repository.GitChangesetConverter;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitUtil;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.util.IOUtil;
@@ -59,10 +60,12 @@ public class GitLogComputer {
private final String repositoryId;
private final Repository gitRepository;
+ private final GitChangesetConverterFactory converterFactory;
- public GitLogComputer(String repositoryId, Repository repository) {
+ public GitLogComputer(String repositoryId, Repository repository, GitChangesetConverterFactory converterFactory) {
this.repositoryId = repositoryId;
this.gitRepository = repository;
+ this.converterFactory = converterFactory;
}
public ChangesetPagingResult compute(LogCommandRequest request) {
@@ -123,7 +126,7 @@ public class GitLogComputer {
revWalk = new RevWalk(gitRepository);
- converter = new GitChangesetConverter(gitRepository, revWalk);
+ converter = converterFactory.create(gitRepository, revWalk);
if (!Strings.isNullOrEmpty(request.getPath())) {
revWalk.setTreeFilter(
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
index 9f362edeb6..bd4e6b26b5 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeCommand.java
@@ -36,6 +36,7 @@ import org.eclipse.jgit.lib.Repository;
import org.eclipse.jgit.merge.ResolveMerger;
import org.eclipse.jgit.treewalk.CanonicalTreeParser;
import org.eclipse.jgit.treewalk.filter.PathFilter;
+import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.GitWorkingCopyFactory;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.api.MergeCommandResult;
@@ -43,6 +44,7 @@ import sonia.scm.repository.api.MergeDryRunCommandResult;
import sonia.scm.repository.api.MergeStrategy;
import sonia.scm.repository.api.MergeStrategyNotSupportedException;
+import javax.inject.Inject;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.util.Set;
@@ -61,6 +63,11 @@ public class GitMergeCommand extends AbstractGitCommand implements MergeCommand
MergeStrategy.SQUASH
);
+ @Inject
+ GitMergeCommand(GitContext context, GitRepositoryHandler handler) {
+ this(context, handler.getWorkingCopyFactory());
+ }
+
GitMergeCommand(GitContext context, GitWorkingCopyFactory workingCopyFactory) {
super(context);
this.workingCopyFactory = workingCopyFactory;
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java
index 0b79ec7204..72a3f23b5f 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitMergeStrategy.java
@@ -56,6 +56,7 @@ abstract class GitMergeStrategy extends AbstractGitCommand.GitCloneWorker doCommit() {
logger.debug("merged branch {} into {}", branchToMerge, targetBranch);
- return doCommit(MessageFormat.format(determineMessageTemplate(), branchToMerge, targetBranch), author);
+ return doCommit(MessageFormat.format(determineMessageTemplate(), branchToMerge, targetBranch), author, sign);
}
MergeCommandResult createSuccessResult(String newRevision) {
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModificationsCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModificationsCommand.java
index 918d276148..e907081f2c 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModificationsCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModificationsCommand.java
@@ -41,6 +41,7 @@ import sonia.scm.repository.Modified;
import sonia.scm.repository.Removed;
import sonia.scm.repository.Renamed;
+import javax.inject.Inject;
import java.io.IOException;
import java.text.MessageFormat;
import java.util.ArrayList;
@@ -53,7 +54,8 @@ import static sonia.scm.ContextEntry.ContextBuilder.entity;
@Slf4j
public class GitModificationsCommand extends AbstractGitCommand implements ModificationsCommand {
- protected GitModificationsCommand(GitContext context) {
+ @Inject
+ GitModificationsCommand(GitContext context) {
super(context);
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java
index 9e0f5449d3..01271d0fc3 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitModifyCommand.java
@@ -34,11 +34,13 @@ import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.ConcurrentModificationException;
import sonia.scm.NoChangesMadeException;
+import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.GitWorkingCopyFactory;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Repository;
import sonia.scm.web.lfs.LfsBlobStoreFactory;
+import javax.inject.Inject;
import java.io.File;
import java.io.IOException;
import java.nio.file.Path;
@@ -53,6 +55,11 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman
private final GitWorkingCopyFactory workingCopyFactory;
private final LfsBlobStoreFactory lfsBlobStoreFactory;
+ @Inject
+ GitModifyCommand(GitContext context, GitRepositoryHandler repositoryHandler, LfsBlobStoreFactory lfsBlobStoreFactory) {
+ this(context, repositoryHandler.getWorkingCopyFactory(), lfsBlobStoreFactory);
+ }
+
GitModifyCommand(GitContext context, GitWorkingCopyFactory workingCopyFactory, LfsBlobStoreFactory lfsBlobStoreFactory) {
super(context);
this.workingCopyFactory = workingCopyFactory;
@@ -86,7 +93,7 @@ public class GitModifyCommand extends AbstractGitCommand implements ModifyComman
r.execute(this);
}
failIfNotChanged(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch()));
- Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor());
+ Optional revCommit = doCommit(request.getCommitMessage(), request.getAuthor(), request.isSign());
push();
return revCommit.orElseThrow(() -> new NoChangesMadeException(repository, ModifyWorker.this.request.getBranch())).name();
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitOutgoingCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitOutgoingCommand.java
index 03acf9e914..30192d4297 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitOutgoingCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitOutgoingCommand.java
@@ -29,8 +29,10 @@ package sonia.scm.repository.spi;
import org.eclipse.jgit.api.LogCommand;
import org.eclipse.jgit.lib.ObjectId;
import sonia.scm.repository.ChangesetPagingResult;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
+import javax.inject.Inject;
import java.io.IOException;
//~--- JDK imports ------------------------------------------------------------
@@ -40,18 +42,12 @@ import java.io.IOException;
* @author Sebastian Sdorra
*/
public class GitOutgoingCommand extends AbstractGitIncomingOutgoingCommand
- implements OutgoingCommand
-{
+ implements OutgoingCommand {
- /**
- * Constructs ...
- *
- * @param handler
- * @param context
- */
- GitOutgoingCommand(GitRepositoryHandler handler, GitContext context)
+ @Inject
+ GitOutgoingCommand(GitContext context, GitRepositoryHandler handler, GitChangesetConverterFactory converterFactory)
{
- super(handler, context);
+ super(context, handler, converterFactory);
}
//~--- get methods ----------------------------------------------------------
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java
index 53b7a59916..422391fd19 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPullCommand.java
@@ -44,6 +44,7 @@ import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.PullResponse;
+import javax.inject.Inject;
import java.io.File;
import java.io.IOException;
import java.net.URL;
@@ -73,6 +74,7 @@ public class GitPullCommand extends AbstractGitPushOrPullCommand
* @param handler
* @param context
*/
+ @Inject
public GitPullCommand(GitRepositoryHandler handler, GitContext context)
{
super(handler, context);
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPushCommand.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPushCommand.java
index fd874524f4..ddbfb6a8e1 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPushCommand.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitPushCommand.java
@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.api.PushResponse;
+import javax.inject.Inject;
import java.io.IOException;
//~--- JDK imports ------------------------------------------------------------
@@ -55,8 +56,8 @@ public class GitPushCommand extends AbstractGitPushOrPullCommand
* @param handler
* @param context
*/
- public GitPushCommand(GitRepositoryHandler handler, GitContext context)
- {
+ @Inject
+ public GitPushCommand(GitRepositoryHandler handler, GitContext context) {
super(handler, context);
this.handler = handler;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
index 862631c32c..fae69a47cf 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceProvider.java
@@ -25,21 +25,14 @@
package sonia.scm.repository.spi;
import com.google.common.collect.ImmutableSet;
-import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider;
-import sonia.scm.event.ScmEventBus;
+import com.google.inject.AbstractModule;
+import com.google.inject.Injector;
import sonia.scm.repository.Feature;
-import sonia.scm.repository.GitRepositoryHandler;
-import sonia.scm.repository.Repository;
import sonia.scm.repository.api.Command;
-import sonia.scm.repository.api.HookContextFactory;
-import sonia.scm.web.lfs.LfsBlobStoreFactory;
-import java.io.IOException;
import java.util.EnumSet;
import java.util.Set;
-//~--- JDK imports ------------------------------------------------------------
-
/**
*
* @author Sebastian Sdorra
@@ -47,8 +40,6 @@ import java.util.Set;
public class GitRepositoryServiceProvider extends RepositoryServiceProvider
{
- /** Field description */
- //J-
public static final Set COMMANDS = ImmutableSet.of(
Command.BLAME,
Command.BROWSE,
@@ -66,105 +57,51 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
Command.MERGE,
Command.MODIFY
);
+
protected static final Set FEATURES = EnumSet.of(Feature.INCOMING_REVISION);
- //J+
+
+ private final GitContext context;
+ private final Injector commandInjector;
//~--- constructors ---------------------------------------------------------
- public GitRepositoryServiceProvider(GitRepositoryHandler handler, Repository repository, GitRepositoryConfigStoreProvider storeProvider, LfsBlobStoreFactory lfsBlobStoreFactory, HookContextFactory hookContextFactory, ScmEventBus eventBus, SyncAsyncExecutorProvider executorProvider) {
- this.handler = handler;
- this.lfsBlobStoreFactory = lfsBlobStoreFactory;
- this.hookContextFactory = hookContextFactory;
- this.eventBus = eventBus;
- this.executorProvider = executorProvider;
- this.context = new GitContext(handler.getDirectory(repository.getId()), repository, storeProvider);
+ GitRepositoryServiceProvider(Injector injector, GitContext context) {
+ this.context = context;
+ commandInjector = injector.createChildInjector(new AbstractModule() {
+ @Override
+ protected void configure() {
+ bind(GitContext.class).toInstance(context);
+ }
+ });
}
- //~--- methods --------------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @throws IOException
- */
@Override
- public void close() throws IOException
- {
- context.close();
- }
-
- //~--- get methods ----------------------------------------------------------
-
- /**
- * Method description
- *
- *
- * @return
- */
- @Override
- public BlameCommand getBlameCommand()
- {
+ public BlameCommand getBlameCommand() {
return new GitBlameCommand(context);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public BranchesCommand getBranchesCommand()
- {
+ public BranchesCommand getBranchesCommand() {
return new GitBranchesCommand(context);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public BranchCommand getBranchCommand()
- {
- return new GitBranchCommand(context, hookContextFactory, eventBus);
+ public BranchCommand getBranchCommand() {
+ return commandInjector.getInstance(GitBranchCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public BrowseCommand getBrowseCommand()
- {
- return new GitBrowseCommand(context, lfsBlobStoreFactory, executorProvider.createExecutorWithDefaultTimeout());
+ public BrowseCommand getBrowseCommand() {
+ return commandInjector.getInstance(GitBrowseCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public CatCommand getCatCommand()
- {
- return new GitCatCommand(context, lfsBlobStoreFactory);
+ public CatCommand getCatCommand() {
+ return commandInjector.getInstance(GitCatCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public DiffCommand getDiffCommand()
- {
+ public DiffCommand getDiffCommand() {
return new GitDiffCommand(context);
}
@@ -173,28 +110,14 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
return new GitDiffResultCommand(context);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public IncomingCommand getIncomingCommand()
- {
- return new GitIncomingCommand(handler, context);
+ public IncomingCommand getIncomingCommand() {
+ return commandInjector.getInstance(GitIncomingCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public LogCommand getLogCommand()
- {
- return new GitLogCommand(context);
+ public LogCommand getLogCommand() {
+ return commandInjector.getInstance(GitLogCommand.class);
}
@Override
@@ -202,93 +125,48 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider
return new GitModificationsCommand(context);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public OutgoingCommand getOutgoingCommand()
- {
- return new GitOutgoingCommand(handler, context);
+ public OutgoingCommand getOutgoingCommand() {
+ return commandInjector.getInstance(GitOutgoingCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public PullCommand getPullCommand()
- {
- return new GitPullCommand(handler, context);
+ public PullCommand getPullCommand() {
+ return commandInjector.getInstance(GitPullCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public PushCommand getPushCommand()
- {
- return new GitPushCommand(handler, context);
+ public PushCommand getPushCommand() {
+ return commandInjector.getInstance(GitPushCommand.class);
}
- /**
- * Method description
- *
- *
- * @return
- */
@Override
- public Set getSupportedCommands()
- {
- return COMMANDS;
- }
-
- /**
- * Method description
- *
- *
- * @return
- */
- @Override
- public TagsCommand getTagsCommand()
- {
+ public TagsCommand getTagsCommand() {
return new GitTagsCommand(context);
}
@Override
public MergeCommand getMergeCommand() {
- return new GitMergeCommand(context, handler.getWorkingCopyFactory());
+ return commandInjector.getInstance(GitMergeCommand.class);
}
@Override
public ModifyCommand getModifyCommand() {
- return new GitModifyCommand(context, handler.getWorkingCopyFactory(), lfsBlobStoreFactory);
+ return commandInjector.getInstance(GitModifyCommand.class);
+ }
+
+ @Override
+ public Set getSupportedCommands() {
+ return COMMANDS;
}
@Override
public Set getSupportedFeatures() {
return FEATURES;
}
-//~--- fields ---------------------------------------------------------------
- /** Field description */
- private final GitContext context;
-
- /** Field description */
- private final GitRepositoryHandler handler;
-
- private final LfsBlobStoreFactory lfsBlobStoreFactory;
-
- private final HookContextFactory hookContextFactory;
-
- private final ScmEventBus eventBus;
-
- private final SyncAsyncExecutorProvider executorProvider;
+ @Override
+ public void close() {
+ context.close();
+ }
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java
index 8ffda05ad3..7ff06dd140 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/repository/spi/GitRepositoryServiceResolver.java
@@ -21,19 +21,16 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.repository.spi;
//~--- non-JDK imports --------------------------------------------------------
import com.google.inject.Inject;
-import sonia.scm.api.v2.resources.GitRepositoryConfigStoreProvider;
-import sonia.scm.event.ScmEventBus;
+import com.google.inject.Injector;
import sonia.scm.plugin.Extension;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.Repository;
-import sonia.scm.repository.api.HookContextFactory;
-import sonia.scm.web.lfs.LfsBlobStoreFactory;
/**
*
@@ -42,31 +39,20 @@ import sonia.scm.web.lfs.LfsBlobStoreFactory;
@Extension
public class GitRepositoryServiceResolver implements RepositoryServiceResolver {
- private final GitRepositoryHandler handler;
- private final GitRepositoryConfigStoreProvider storeProvider;
- private final LfsBlobStoreFactory lfsBlobStoreFactory;
- private final HookContextFactory hookContextFactory;
- private final ScmEventBus eventBus;
- private final SyncAsyncExecutorProvider executorProvider;
+ private final Injector injector;
+ private final GitContextFactory contextFactory;
@Inject
- public GitRepositoryServiceResolver(GitRepositoryHandler handler, GitRepositoryConfigStoreProvider storeProvider, LfsBlobStoreFactory lfsBlobStoreFactory, HookContextFactory hookContextFactory, ScmEventBus eventBus, SyncAsyncExecutorProvider executorProvider) {
- this.handler = handler;
- this.storeProvider = storeProvider;
- this.lfsBlobStoreFactory = lfsBlobStoreFactory;
- this.hookContextFactory = hookContextFactory;
- this.eventBus = eventBus;
- this.executorProvider = executorProvider;
+ public GitRepositoryServiceResolver(Injector injector, GitContextFactory contextFactory) {
+ this.injector = injector;
+ this.contextFactory = contextFactory;
}
@Override
public GitRepositoryServiceProvider resolve(Repository repository) {
- GitRepositoryServiceProvider provider = null;
-
if (GitRepositoryHandler.TYPE_NAME.equalsIgnoreCase(repository.getType())) {
- provider = new GitRepositoryServiceProvider(handler, repository, storeProvider, lfsBlobStoreFactory, hookContextFactory, eventBus, executorProvider);
+ return new GitRepositoryServiceProvider(injector, contextFactory.create(repository));
}
-
- return provider;
+ return null;
}
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java
index a9d74e0357..a093b5cda3 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceiveHook.java
@@ -34,6 +34,7 @@ import org.eclipse.jgit.transport.ReceiveCommand;
import org.eclipse.jgit.transport.ReceivePack;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
+import sonia.scm.repository.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.RepositoryHookType;
import sonia.scm.repository.spi.GitHookContextProvider;
@@ -66,9 +67,10 @@ public class GitReceiveHook implements PreReceiveHook, PostReceiveHook
* @param hookEventFacade
* @param handler
*/
- public GitReceiveHook(HookEventFacade hookEventFacade,
- GitRepositoryHandler handler)
+ public GitReceiveHook(GitChangesetConverterFactory converterFactory, HookEventFacade hookEventFacade,
+ GitRepositoryHandler handler)
{
+ this.converterFactory = converterFactory;
this.hookEventFacade = hookEventFacade;
this.handler = handler;
}
@@ -122,7 +124,7 @@ public class GitReceiveHook implements PreReceiveHook, PostReceiveHook
logger.trace("resolved repository to {}", repositoryId);
- GitHookContextProvider context = new GitHookContextProvider(rpack, receiveCommands, repository, repositoryId);
+ GitHookContextProvider context = new GitHookContextProvider(converterFactory, rpack, receiveCommands, repository, repositoryId);
hookEventFacade.handle(repositoryId).fireHookEvent(type, context);
@@ -187,6 +189,7 @@ public class GitReceiveHook implements PreReceiveHook, PostReceiveHook
/** Field description */
private GitRepositoryHandler handler;
+ private final GitChangesetConverterFactory converterFactory;
/** Field description */
private HookEventFacade hookEventFacade;
}
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java
index b4b43ea5b5..dc18189da9 100644
--- a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/GitReceivePackFactory.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.web;
//~--- non-JDK imports --------------------------------------------------------
@@ -34,6 +34,7 @@ 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.GitChangesetConverterFactory;
import sonia.scm.repository.GitRepositoryHandler;
import sonia.scm.repository.spi.HookEventFacade;
@@ -53,8 +54,8 @@ public class GitReceivePackFactory extends BaseReceivePackFactory wrapped;
@Inject
- public GitReceivePackFactory(GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
- super(handler, hookEventFacade);
+ public GitReceivePackFactory(GitChangesetConverterFactory converterFactory, GitRepositoryHandler handler, HookEventFacade hookEventFacade) {
+ super(converterFactory, handler, hookEventFacade);
this.wrapped = new DefaultReceivePackFactory();
}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java
index 8fa2ecddaa..cc178c2ef8 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/protocolcommand/git/BaseReceivePackFactoryTest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.protocolcommand.git;
import org.eclipse.jgit.api.Git;
@@ -40,6 +40,7 @@ import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.repository.GitConfig;
import sonia.scm.repository.GitRepositoryHandler;
+import sonia.scm.repository.GitTestHelper;
import sonia.scm.web.CollectingPackParserListener;
import sonia.scm.web.GitReceiveHook;
@@ -82,7 +83,7 @@ public class BaseReceivePackFactoryTest {
ReceivePack receivePack = new ReceivePack(repository);
when(wrappedReceivePackFactory.create(request, repository)).thenReturn(receivePack);
- factory = new BaseReceivePackFactory
>
);
diff --git a/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx b/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx
index a57f2dfd22..fd9c55d32b 100644
--- a/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx
+++ b/scm-ui/ui-webapp/src/repos/containers/ChangesetView.tsx
@@ -35,11 +35,13 @@ import {
isFetchChangesetPending
} from "../modules/changesets";
import ChangesetDetails from "../components/changesets/ChangesetDetails";
+import { FileControlFactory } from "@scm-manager/ui-components";
type Props = WithTranslation & {
id: string;
changeset: Changeset;
repository: Repository;
+ fileControlFactoryFactory?: (changeset: Changeset) => FileControlFactory;
loading: boolean;
error: Error;
fetchChangesetIfNeeded: (repository: Repository, id: string) => void;
@@ -60,7 +62,7 @@ class ChangesetView extends React.Component {
}
render() {
- const { changeset, loading, error, t, repository } = this.props;
+ const { changeset, loading, error, t, repository, fileControlFactoryFactory } = this.props;
if (error) {
return ;
@@ -68,7 +70,13 @@ class ChangesetView extends React.Component {
if (!changeset || loading) return ;
- return ;
+ return (
+
+ );
}
}
diff --git a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx
index 5b01e7e225..abbaf74eed 100644
--- a/scm-ui/ui-webapp/src/repos/containers/Overview.tsx
+++ b/scm-ui/ui-webapp/src/repos/containers/Overview.tsx
@@ -25,7 +25,6 @@ import React from "react";
import { connect } from "react-redux";
import { RouteComponentProps, withRouter } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
-import { History } from "history";
import { RepositoryCollection } from "@scm-manager/ui-types";
import {
CreateButton,
@@ -77,11 +76,17 @@ class Overview extends React.Component {
render() {
const { error, loading, showCreateButton, t } = this.props;
+
return (
{this.renderOverview()}
-
+
);
diff --git a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx
index f3d1aa6ac1..2981ecad28 100644
--- a/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx
+++ b/scm-ui/ui-webapp/src/repos/containers/RepositoryRoot.tsx
@@ -23,21 +23,21 @@
*/
import React from "react";
import { connect } from "react-redux";
-import { Redirect, Route, Switch, RouteComponentProps } from "react-router-dom";
+import { Redirect, Route, RouteComponentProps, Switch } from "react-router-dom";
import { WithTranslation, withTranslation } from "react-i18next";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions";
-import { Repository } from "@scm-manager/ui-types";
+import { Changeset, Repository } from "@scm-manager/ui-types";
import {
+ CustomQueryFlexWrappedColumns,
ErrorPage,
Loading,
NavLink,
Page,
- CustomQueryFlexWrappedColumns,
PrimaryContentColumn,
- SecondaryNavigationColumn,
SecondaryNavigation,
- SubNavigation,
- StateMenuContextProvider
+ SecondaryNavigationColumn,
+ StateMenuContextProvider,
+ SubNavigation
} from "@scm-manager/ui-components";
import { fetchRepoByName, getFetchRepoFailure, getRepository, isFetchRepoPending } from "../modules/repos";
import RepositoryDetails from "../components/RepositoryDetails";
@@ -53,6 +53,7 @@ import { getLinks, getRepositoriesLink } from "../../modules/indexResource";
import CodeOverview from "../codeSection/containers/CodeOverview";
import ChangesetView from "./ChangesetView";
import SourceExtensions from "../sources/containers/SourceExtensions";
+import { FileControlFactory, JumpToFileButton } from "@scm-manager/ui-components";
type Props = RouteComponentProps &
WithTranslation & {
@@ -117,7 +118,7 @@ class RepositoryRoot extends React.Component {
evaluateDestinationForCodeLink = () => {
const { repository } = this.props;
- let url = `${this.matchedUrl()}/code`;
+ const url = `${this.matchedUrl()}/code`;
if (repository?._links?.sources) {
return `${url}/sources/`;
}
@@ -153,6 +154,38 @@ class RepositoryRoot extends React.Component {
redirectedUrl = url + "/info";
}
+ const fileControlFactoryFactory: (changeset: Changeset) => FileControlFactory = changeset => file => {
+ const baseUrl = `${url}/code/sources`;
+ const sourceLink = {
+ url: `${baseUrl}/${changeset.id}/${file.newPath}/`,
+ label: t("diff.jumpToSource")
+ };
+ const targetLink = changeset._embedded?.parents?.length === 1 && {
+ url: `${baseUrl}/${changeset._embedded.parents[0].id}/${file.oldPath}`,
+ label: t("diff.jumpToTarget")
+ };
+
+ const links = [];
+ switch (file.type) {
+ case "add":
+ links.push(sourceLink);
+ break;
+ case "delete":
+ if (targetLink) {
+ links.push(targetLink);
+ }
+ break;
+ default:
+ if (targetLink) {
+ links.push(targetLink, sourceLink); // Target link first because its the previous file
+ } else {
+ links.push(sourceLink);
+ }
+ }
+
+ return links.map(({ url, label }) => );
+ };
+
return (
{
}
+ render={() => (
+
+ )}
/>
{
if (!this.isEditable()) {
return null;
}
- return ;
+ return ;
}
}
diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx b/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx
index 3c7dd9e9c8..978c78b833 100644
--- a/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx
+++ b/scm-ui/ui-webapp/src/users/components/navLinks/SetPasswordNavLink.tsx
@@ -38,7 +38,7 @@ class ChangePasswordNavLink extends React.Component {
if (!this.hasPermissionToSetPassword()) {
return null;
}
- return ;
+ return ;
}
hasPermissionToSetPassword = () => {
diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/SetPermissionsNavLink.tsx b/scm-ui/ui-webapp/src/users/components/navLinks/SetPermissionsNavLink.tsx
index 73d7c7541c..d0e82f78dc 100644
--- a/scm-ui/ui-webapp/src/users/components/navLinks/SetPermissionsNavLink.tsx
+++ b/scm-ui/ui-webapp/src/users/components/navLinks/SetPermissionsNavLink.tsx
@@ -38,7 +38,7 @@ class ChangePermissionNavLink extends React.Component {
if (!this.hasPermissionToSetPermission()) {
return null;
}
- return ;
+ return ;
}
hasPermissionToSetPermission = () => {
diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/SetPublicKeysNavLink.tsx b/scm-ui/ui-webapp/src/users/components/navLinks/SetPublicKeysNavLink.tsx
new file mode 100644
index 0000000000..f3144442f5
--- /dev/null
+++ b/scm-ui/ui-webapp/src/users/components/navLinks/SetPublicKeysNavLink.tsx
@@ -0,0 +1,43 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+import React, { FC } from "react";
+import { Link, User, Me } from "@scm-manager/ui-types";
+import { NavLink } from "@scm-manager/ui-components";
+import { useTranslation } from "react-i18next";
+
+type Props = {
+ user: User | Me;
+ publicKeyUrl: string;
+};
+
+const SetPublicKeyNavLink: FC = ({ user, publicKeyUrl }) => {
+ const [t] = useTranslation("users");
+
+ if ((user?._links?.publicKeys as Link)?.href) {
+ return ;
+ }
+ return null;
+};
+
+export default SetPublicKeyNavLink;
diff --git a/scm-ui/ui-webapp/src/users/components/navLinks/index.ts b/scm-ui/ui-webapp/src/users/components/navLinks/index.ts
index 0ccd16b42a..f732ea83ee 100644
--- a/scm-ui/ui-webapp/src/users/components/navLinks/index.ts
+++ b/scm-ui/ui-webapp/src/users/components/navLinks/index.ts
@@ -25,3 +25,4 @@
export { default as EditUserNavLink } from "./EditUserNavLink";
export { default as SetPasswordNavLink } from "./SetPasswordNavLink";
export { default as SetPermissionsNavLink } from "./SetPermissionsNavLink";
+export { default as SetPublicKeysNavLink } from "./SetPublicKeysNavLink";
diff --git a/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx b/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx
new file mode 100644
index 0000000000..e0129e8245
--- /dev/null
+++ b/scm-ui/ui-webapp/src/users/components/publicKeys/AddPublicKey.tsx
@@ -0,0 +1,89 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import React, { FC, useState } from "react";
+import { User, Link, Links, Collection } from "@scm-manager/ui-types/src";
+import {
+ ErrorNotification,
+ InputField,
+ Level,
+ Textarea,
+ SubmitButton,
+ apiClient,
+ Loading
+} from "@scm-manager/ui-components";
+import { useTranslation } from "react-i18next";
+import { CONTENT_TYPE_PUBLIC_KEY } from "./SetPublicKeys";
+
+type Props = {
+ createLink: string;
+ refresh: () => void;
+};
+
+const AddPublicKey: FC = ({ createLink, refresh }) => {
+ const [t] = useTranslation("users");
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState();
+ const [displayName, setDisplayName] = useState("");
+ const [raw, setRaw] = useState("");
+
+ const isValid = () => {
+ return !!displayName && !!raw;
+ };
+
+ const resetForm = () => {
+ setDisplayName("");
+ setRaw("");
+ };
+
+ const addKey = () => {
+ setLoading(true);
+ apiClient
+ .post(createLink, { displayName: displayName, raw: raw }, CONTENT_TYPE_PUBLIC_KEY)
+ .then(resetForm)
+ .then(refresh)
+ .then(() => setLoading(false))
+ .catch(setError);
+ };
+
+ if (error) {
+ return ;
+ }
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+ <>
+
+
+ }
+ />
+ >
+ );
+};
+
+export default AddPublicKey;
diff --git a/scm-ui/ui-webapp/src/users/components/publicKeys/PublicKeyEntry.tsx b/scm-ui/ui-webapp/src/users/components/publicKeys/PublicKeyEntry.tsx
new file mode 100644
index 0000000000..1989a5706a
--- /dev/null
+++ b/scm-ui/ui-webapp/src/users/components/publicKeys/PublicKeyEntry.tsx
@@ -0,0 +1,61 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import React, {FC} from "react";
+import {DateFromNow, DeleteButton} from "@scm-manager/ui-components";
+import {PublicKey} from "./SetPublicKeys";
+import {useTranslation} from "react-i18next";
+import {Link} from "@scm-manager/ui-types";
+
+type Props = {
+ publicKey: PublicKey;
+ onDelete: (link: string) => void;
+};
+
+export const PublicKeyEntry: FC = ({publicKey, onDelete}) => {
+ const [t] = useTranslation("users");
+
+ let deleteButton;
+ if (publicKey?._links?.delete) {
+ deleteButton = (
+ onDelete((publicKey._links.delete as Link).href)}/>
+ );
+ }
+
+ return (
+ <>
+
+ >
+ );
+};
+
+export default PublicKeyEntry;
diff --git a/scm-ui/ui-webapp/src/users/components/publicKeys/PublicKeyTable.tsx b/scm-ui/ui-webapp/src/users/components/publicKeys/PublicKeyTable.tsx
new file mode 100644
index 0000000000..3172b1429a
--- /dev/null
+++ b/scm-ui/ui-webapp/src/users/components/publicKeys/PublicKeyTable.tsx
@@ -0,0 +1,62 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import React, { FC } from "react";
+import { useTranslation } from "react-i18next";
+import { PublicKey, PublicKeysCollection } from "./SetPublicKeys";
+import PublicKeyEntry from "./PublicKeyEntry";
+import { Notification } from "@scm-manager/ui-components";
+
+type Props = {
+ publicKeys?: PublicKeysCollection;
+ onDelete: (link: string) => void;
+};
+
+const PublicKeyTable: FC = ({ publicKeys, onDelete }) => {
+ const [t] = useTranslation("users");
+
+ if (publicKeys?._embedded?.keys?.length === 0) {
+ return {t("publicKey.noStoredKeys")};
+ }
+
+ return (
+
+ );
+};
+
+export default PublicKeyTable;
diff --git a/scm-ui/ui-webapp/src/users/components/publicKeys/SetPublicKeys.tsx b/scm-ui/ui-webapp/src/users/components/publicKeys/SetPublicKeys.tsx
new file mode 100644
index 0000000000..c975a83533
--- /dev/null
+++ b/scm-ui/ui-webapp/src/users/components/publicKeys/SetPublicKeys.tsx
@@ -0,0 +1,95 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+import { Collection, Link, Links, User, Me } from "@scm-manager/ui-types";
+import React, { FC, useEffect, useState } from "react";
+import AddPublicKey from "./AddPublicKey";
+import PublicKeyTable from "./PublicKeyTable";
+import { apiClient, ErrorNotification, Loading } from "@scm-manager/ui-components";
+
+export type PublicKeysCollection = Collection & {
+ _embedded: {
+ keys: PublicKey[];
+ };
+};
+
+export type PublicKey = {
+ id: string;
+ displayName: string;
+ raw: string;
+ created?: string;
+ _links: Links;
+};
+
+export const CONTENT_TYPE_PUBLIC_KEY = "application/vnd.scmm-publicKey+json;v=2";
+
+type Props = {
+ user: User | Me;
+};
+
+const SetPublicKeys: FC = ({ user }) => {
+ const [error, setError] = useState();
+ const [loading, setLoading] = useState(false);
+ const [publicKeys, setPublicKeys] = useState(undefined);
+
+ useEffect(() => {
+ fetchPublicKeys();
+ }, [user]);
+
+ const fetchPublicKeys = () => {
+ setLoading(true);
+ apiClient
+ .get((user._links.publicKeys as Link).href)
+ .then(r => r.json())
+ .then(setPublicKeys)
+ .then(() => setLoading(false))
+ .catch(setError);
+ };
+
+ const onDelete = (link: string) => {
+ apiClient
+ .delete(link)
+ .then(fetchPublicKeys)
+ .catch(setError);
+ };
+
+ const createLink = (publicKeys?._links?.create as Link)?.href;
+
+ if (error) {
+ return ;
+ }
+
+ if (loading) {
+ return ;
+ }
+
+ return (
+ <>
+
+ {createLink && }
+ >
+ );
+};
+
+export default SetPublicKeys;
diff --git a/scm-ui/ui-webapp/src/users/components/table/Details.tsx b/scm-ui/ui-webapp/src/users/components/table/Details.tsx
index 9dea9d1c2c..18df0cdf49 100644
--- a/scm-ui/ui-webapp/src/users/components/table/Details.tsx
+++ b/scm-ui/ui-webapp/src/users/components/table/Details.tsx
@@ -24,7 +24,12 @@
import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { User } from "@scm-manager/ui-types";
-import { Checkbox, DateFromNow, MailLink } from "@scm-manager/ui-components";
+import {
+ Checkbox,
+ DateFromNow,
+ MailLink,
+ createAttributesForTesting
+} from "@scm-manager/ui-components";
type Props = WithTranslation & {
user: User;
@@ -38,11 +43,11 @@ class Details extends React.Component {
{t("user.name")}
-
{user.name}
+
{user.name}
{t("user.displayName")}
-
{user.displayName}
+
{user.displayName}
{t("user.mail")}
diff --git a/scm-ui/ui-webapp/src/users/components/table/UserRow.tsx b/scm-ui/ui-webapp/src/users/components/table/UserRow.tsx
index 0660fc989c..060200f490 100644
--- a/scm-ui/ui-webapp/src/users/components/table/UserRow.tsx
+++ b/scm-ui/ui-webapp/src/users/components/table/UserRow.tsx
@@ -25,7 +25,7 @@ import React from "react";
import { WithTranslation, withTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { User } from "@scm-manager/ui-types";
-import { Icon } from "@scm-manager/ui-components";
+import { Icon, createAttributesForTesting } from "@scm-manager/ui-components";
type Props = WithTranslation & {
user: User;
@@ -33,7 +33,11 @@ type Props = WithTranslation & {
class UserRow extends React.Component {
renderLink(to: string, label: string) {
- return {label};
+ return (
+
+ {label}
+
+ );
}
render() {
diff --git a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx
index ff2c57f0e6..ceea726797 100644
--- a/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx
+++ b/scm-ui/ui-webapp/src/users/containers/SingleUser.tsx
@@ -41,11 +41,13 @@ import {
import { Details } from "./../components/table";
import EditUser from "./EditUser";
import { fetchUserByName, getFetchUserFailure, getUserByName, isFetchUserPending } from "../modules/users";
-import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink } from "./../components/navLinks";
+import { EditUserNavLink, SetPasswordNavLink, SetPermissionsNavLink, SetPublicKeysNavLink } from "./../components/navLinks";
import { WithTranslation, withTranslation } from "react-i18next";
import { getUsersLink } from "../../modules/indexResource";
import SetUserPassword from "../components/SetUserPassword";
import SetPermissions from "../../permissions/components/SetPermissions";
+import AddPublicKey from "../components/publicKeys/AddPublicKey";
+import SetPublicKeys from "../components/publicKeys/SetPublicKeys";
type Props = RouteComponentProps &
WithTranslation & {
@@ -105,6 +107,10 @@ class SingleUser extends React.Component {
path={`${url}/settings/permissions`}
component={() => }
/>
+ }
+ />
@@ -114,15 +120,18 @@ class SingleUser extends React.Component {
icon="fas fa-info-circle"
label={t("singleUser.menu.informationNavLink")}
title={t("singleUser.menu.informationNavLink")}
+ testId="user-information-link"
/>
+
diff --git a/scm-ui/ui-webapp/src/users/containers/Users.tsx b/scm-ui/ui-webapp/src/users/containers/Users.tsx
index d358fc2a92..fe6a372c2b 100644
--- a/scm-ui/ui-webapp/src/users/containers/Users.tsx
+++ b/scm-ui/ui-webapp/src/users/containers/Users.tsx
@@ -25,7 +25,6 @@ import React from "react";
import { connect } from "react-redux";
import { WithTranslation, withTranslation } from "react-i18next";
import { RouteComponentProps } from "react-router-dom";
-import { History } from "history";
import { PagedCollection, User } from "@scm-manager/ui-types";
import {
CreateButton,
@@ -88,6 +87,7 @@ class Users extends React.Component {
render() {
const { users, loading, error, canAddUsers, t } = this.props;
+
return (
{this.renderUserTable()}
diff --git a/scm-webapp/pom.xml b/scm-webapp/pom.xml
index 31d18327f0..b40830fca6 100644
--- a/scm-webapp/pom.xml
+++ b/scm-webapp/pom.xml
@@ -114,6 +114,23 @@
${jjwt.version}
+
+
+
+ org.bouncycastle
+ bcpg-jdk15on
+
+
+
+ org.bouncycastle
+ bcprov-jdk15on
+
+
+
+ org.bouncycastle
+ bcpkix-jdk15on
+
+
@@ -222,12 +239,6 @@
${guice.version}
-
- com.google.inject.extensions
- guice-assistedinject
- ${guice.version}
-
-
@@ -650,7 +661,7 @@
org.basepom.mavenduplicate-finder-maven-plugin
- 1.3.0
+ 1.4.0default
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java
index c81b62c8f4..c219d61ad8 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDto.java
@@ -29,6 +29,7 @@ import de.otto.edison.hal.Links;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.Setter;
+import sonia.scm.security.AnonymousMode;
import java.util.Set;
@@ -46,6 +47,7 @@ public class ConfigDto extends HalRepresentation {
private boolean disableGroupingGrid;
private String dateFormat;
private boolean anonymousAccessEnabled;
+ private AnonymousMode anonymousMode;
private String baseUrl;
private boolean forceBaseUrl;
private int loginAttemptLimit;
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapper.java
index cd65ff3741..993e377dda 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapper.java
@@ -21,11 +21,14 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
+import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
+import org.mapstruct.MappingTarget;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
// Mapstruct does not support parameterized (i.e. non-default) constructors. Thus, we need to use field injection.
@SuppressWarnings("squid:S3306")
@@ -33,4 +36,15 @@ import sonia.scm.config.ScmConfiguration;
public abstract class ConfigDtoToScmConfigurationMapper {
public abstract ScmConfiguration map(ConfigDto dto);
+
+ @AfterMapping // Should map anonymous mode from old flag if not send explicit
+ void mapAnonymousMode(@MappingTarget ScmConfiguration config, ConfigDto configDto) {
+ if (configDto.getAnonymousMode() == null) {
+ if (configDto.isAnonymousAccessEnabled()) {
+ config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
+ } else {
+ config.setAnonymousMode(AnonymousMode.OFF);
+ }
+ }
+ }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java
index 2314cd0aa3..384331c6ad 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/DefaultChangesetToChangesetDtoMapper.java
@@ -31,17 +31,22 @@ import org.mapstruct.Mapper;
import org.mapstruct.ObjectFactory;
import sonia.scm.repository.Branch;
import sonia.scm.repository.Changeset;
+import sonia.scm.repository.Contributor;
import sonia.scm.repository.Person;
import sonia.scm.repository.Repository;
+import sonia.scm.repository.Signature;
import sonia.scm.repository.Tag;
-import sonia.scm.repository.Contributor;
import sonia.scm.repository.api.Command;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
+import sonia.scm.security.gpg.PublicKeyResource;
+import sonia.scm.security.gpg.PublicKeyStore;
+import sonia.scm.security.gpg.RawGpgKey;
import sonia.scm.web.EdisonHalAppender;
import javax.inject.Inject;
import java.util.List;
+import java.util.Optional;
import java.util.function.Function;
import java.util.stream.Collectors;
@@ -67,10 +72,34 @@ public abstract class DefaultChangesetToChangesetDtoMapper extends HalAppenderMa
@Inject
private TagCollectionToDtoMapper tagCollectionToDtoMapper;
+ @Inject
+ private ScmPathInfoStore scmPathInfoStore;
+
+ @Inject
+ private PublicKeyStore publicKeyStore;
+
abstract ContributorDto map(Contributor contributor);
+ abstract SignatureDto map(Signature signature);
+
abstract PersonDto map(Person person);
+ @ObjectFactory
+ SignatureDto createDto(Signature signature) {
+ final Optional key = publicKeyStore.findById(signature.getKeyId());
+ if (signature.getType().equals("gpg") && key.isPresent()) {
+ final Links.Builder linkBuilder =
+ linkingTo()
+ .single(link("rawKey", new LinkBuilder(scmPathInfoStore.get(), PublicKeyResource.class)
+ .method("findByIdGpg")
+ .parameters(signature.getKeyId())
+ .href()));
+
+ return new SignatureDto(linkBuilder.build());
+ }
+ return new SignatureDto();
+ }
+
@ObjectFactory
ChangesetDto createDto(@Context Repository repository, Changeset source) {
String namespace = repository.getNamespace();
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java
index 2a09850117..e7f4ce4e85 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/GroupCollectionResource.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import io.swagger.v3.oas.annotations.Operation;
@@ -31,6 +31,7 @@ import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import sonia.scm.group.Group;
import sonia.scm.group.GroupManager;
+import sonia.scm.group.GroupPermissions;
import sonia.scm.search.SearchRequest;
import sonia.scm.search.SearchUtil;
import sonia.scm.web.VndMediaType;
@@ -106,6 +107,7 @@ public class GroupCollectionResource {
@QueryParam("desc") boolean desc,
@DefaultValue("") @QueryParam("q") String search
) {
+ GroupPermissions.list().check();
return adapter.getAll(page, pageSize, createSearchPredicate(search), sortBy, desc,
pageResult -> groupCollectionToDtoMapper.map(page, pageSize, pageResult));
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
index 41167a221f..1339c55097 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/IndexDtoGenerator.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import com.google.common.base.Strings;
@@ -35,6 +35,7 @@ import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.group.GroupPermissions;
import sonia.scm.plugin.PluginPermissions;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.security.Authentications;
import sonia.scm.security.PermissionPermissions;
import sonia.scm.user.UserPermissions;
@@ -70,7 +71,7 @@ public class IndexDtoGenerator extends HalAppenderMapper {
builder.single(link("loginInfo", loginInfoUrl));
}
- if (SecurityUtils.getSubject().isAuthenticated()) {
+ if (shouldAppendSubjectRelatedLinks()) {
builder.single(link("me", resourceLinks.me().self()));
if (Authentications.isAuthenticatedSubjectAnonymous()) {
@@ -120,4 +121,19 @@ public class IndexDtoGenerator extends HalAppenderMapper {
return new IndexDto(builder.build(), embeddedBuilder.build(), scmContextProvider.getVersion());
}
+
+ private boolean shouldAppendSubjectRelatedLinks() {
+ return isAuthenticatedSubjectNotAnonymous()
+ || isAuthenticatedSubjectAllowedToBeAnonymous();
+ }
+
+ private boolean isAuthenticatedSubjectAllowedToBeAnonymous() {
+ return Authentications.isAuthenticatedSubjectAnonymous()
+ && configuration.getAnonymousMode() == AnonymousMode.FULL;
+ }
+
+ private boolean isAuthenticatedSubjectNotAnonymous() {
+ return SecurityUtils.getSubject().isAuthenticated()
+ && !Authentications.isAuthenticatedSubjectAnonymous();
+ }
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
index a45b52ddbe..b6c8e9d8ea 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MapperModule.java
@@ -27,6 +27,7 @@ package sonia.scm.api.v2.resources;
import com.google.inject.AbstractModule;
import com.google.inject.servlet.ServletScopes;
import org.mapstruct.factory.Mappers;
+import sonia.scm.security.gpg.PublicKeyMapper;
import sonia.scm.web.api.RepositoryToHalMapper;
public class MapperModule extends AbstractModule {
@@ -35,6 +36,7 @@ public class MapperModule extends AbstractModule {
bind(UserDtoToUserMapper.class).to(Mappers.getMapperClass(UserDtoToUserMapper.class));
bind(UserToUserDtoMapper.class).to(Mappers.getMapperClass(UserToUserDtoMapper.class));
bind(UserCollectionToDtoMapper.class);
+ bind(PublicKeyMapper.class).to(Mappers.getMapperClass(PublicKeyMapper.class));
bind(GroupDtoToGroupMapper.class).to(Mappers.getMapperClass(GroupDtoToGroupMapper.class));
bind(GroupToGroupDtoMapper.class).to(Mappers.getMapperClass(GroupToGroupDtoMapper.class));
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java
index 5308402540..705573e3c3 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/MeDtoFactory.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Embedded;
@@ -30,7 +30,6 @@ import org.apache.shiro.SecurityUtils;
import org.apache.shiro.subject.PrincipalCollection;
import org.apache.shiro.subject.Subject;
import sonia.scm.group.GroupCollector;
-import sonia.scm.security.Authentications;
import sonia.scm.user.User;
import sonia.scm.user.UserManager;
import sonia.scm.user.UserPermissions;
@@ -89,7 +88,10 @@ public class MeDtoFactory extends HalAppenderMapper {
if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.me().update(user.getName())));
}
- if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted() && !Authentications.isSubjectAnonymous(user.getName())) {
+ if (UserPermissions.changePublicKeys(user).isPermitted()) {
+ linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
+ }
+ if (userManager.isTypeDefault(user) && UserPermissions.changePassword(user).isPermitted()) {
linksBuilder.single(link("password", resourceLinks.me().passwordChange()));
}
@@ -98,5 +100,4 @@ public class MeDtoFactory extends HalAppenderMapper {
return new MeDto(linksBuilder.build(), embeddedBuilder.build());
}
-
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
index 3b76961594..1ed150a2f1 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ResourceLinks.java
@@ -25,6 +25,7 @@
package sonia.scm.api.v2.resources;
import sonia.scm.repository.NamespaceAndName;
+import sonia.scm.security.gpg.UserPublicKeyResource;
import javax.inject.Inject;
import java.net.URI;
@@ -99,9 +100,11 @@ class ResourceLinks {
static class UserLinks {
private final LinkBuilder userLinkBuilder;
+ private final LinkBuilder publicKeyLinkBuilder;
UserLinks(ScmPathInfo pathInfo) {
userLinkBuilder = new LinkBuilder(pathInfo, UserRootResource.class, UserResource.class);
+ publicKeyLinkBuilder = new LinkBuilder(pathInfo, UserPublicKeyResource.class);
}
String self(String name) {
@@ -119,6 +122,10 @@ class ResourceLinks {
public String passwordChange(String name) {
return userLinkBuilder.method("getUserResource").parameters(name).method("overwritePassword").parameters().href();
}
+
+ public String publicKeys(String name) {
+ return publicKeyLinkBuilder.method("findAll").parameters(name).href();
+ }
}
interface WithPermissionLinks {
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapper.java
index b15c2e7e10..6288211004 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapper.java
@@ -27,9 +27,12 @@ package sonia.scm.api.v2.resources;
import de.otto.edison.hal.Links;
import org.mapstruct.AfterMapping;
import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
import org.mapstruct.MappingTarget;
+import org.mapstruct.Named;
import sonia.scm.config.ConfigurationPermissions;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import javax.inject.Inject;
@@ -44,6 +47,15 @@ public abstract class ScmConfigurationToConfigDtoMapper extends BaseMapper userCollectionToDtoMapper.map(page, pageSize, pageResult));
}
diff --git a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java
index 322c951962..761de187f1 100644
--- a/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java
+++ b/scm-webapp/src/main/java/sonia/scm/api/v2/resources/UserToUserDtoMapper.java
@@ -65,6 +65,7 @@ public abstract class UserToUserDtoMapper extends BaseMapper {
}
if (UserPermissions.modify(user).isPermitted()) {
linksBuilder.single(link("update", resourceLinks.user().update(user.getName())));
+ linksBuilder.single(link("publicKeys", resourceLinks.user().publicKeys(user.getName())));
if (userManager.isTypeDefault(user)) {
linksBuilder.single(link("password", resourceLinks.user().passwordChange(user.getName())));
}
diff --git a/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java b/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java
index 86ec288f80..6711d753a4 100644
--- a/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java
+++ b/scm-webapp/src/main/java/sonia/scm/filter/PropagatePrincipleFilter.java
@@ -33,6 +33,7 @@ import org.apache.shiro.subject.Subject;
import sonia.scm.Priority;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.web.filter.HttpFilter;
import sonia.scm.web.filter.PropagatePrincipleServletRequestWrapper;
@@ -89,7 +90,7 @@ public class PropagatePrincipleFilter extends HttpFilter
private boolean hasPermission(Subject subject)
{
return ((configuration != null)
- && configuration.isAnonymousAccessEnabled()) || subject.isAuthenticated()
+ && configuration.getAnonymousMode() != AnonymousMode.OFF) || subject.isAuthenticated()
|| subject.isRemembered();
}
diff --git a/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java b/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java
index 0f84a9f629..af95c09aa8 100644
--- a/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java
+++ b/scm-webapp/src/main/java/sonia/scm/lifecycle/SetupContextListener.java
@@ -31,6 +31,7 @@ import org.slf4j.LoggerFactory;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.plugin.Extension;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.User;
@@ -94,7 +95,7 @@ public class SetupContextListener implements ServletContextListener {
}
private boolean anonymousUserRequiredButNotExists() {
- return scmConfiguration.isAnonymousAccessEnabled() && !userManager.contains(SCMContext.USER_ANONYMOUS);
+ return scmConfiguration.getAnonymousMode() != AnonymousMode.OFF && !userManager.contains(SCMContext.USER_ANONYMOUS);
}
private boolean shouldCreateAdminAccount() {
diff --git a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java
index a2989744cb..e037590db6 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/BearerRealm.java
@@ -92,6 +92,7 @@ public class BearerRealm extends AuthenticatingRealm
checkArgument(token instanceof BearerToken, "%s is required", BearerToken.class);
BearerToken bt = (BearerToken) token;
+
AccessToken accessToken = tokenResolver.resolve(bt);
return helper.authenticationInfoBuilder(accessToken.getSubject())
diff --git a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java
index d8d295ac7b..8f89e7f557 100644
--- a/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java
+++ b/scm-webapp/src/main/java/sonia/scm/security/JwtAccessTokenResolver.java
@@ -21,22 +21,24 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.security;
import io.jsonwebtoken.Claims;
+import io.jsonwebtoken.ExpiredJwtException;
import io.jsonwebtoken.JwtException;
import io.jsonwebtoken.Jwts;
-import java.util.Set;
-import javax.inject.Inject;
import org.apache.shiro.authc.AuthenticationException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.plugin.Extension;
+import javax.inject.Inject;
+import java.util.Set;
+
/**
* Jwt implementation of {@link AccessTokenResolver}.
- *
+ *
* @author Sebastian Sdorra
* @since 2.0.0
*/
@@ -47,7 +49,7 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
* the logger for JwtAccessTokenResolver
*/
private static final Logger LOG = LoggerFactory.getLogger(JwtAccessTokenResolver.class);
-
+
private final SecureKeyResolver keyResolver;
private final Set validators;
@@ -56,7 +58,7 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
this.keyResolver = keyResolver;
this.validators = validators;
}
-
+
@Override
public JwtAccessToken resolve(BearerToken bearerToken) {
try {
@@ -71,6 +73,8 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
validate(token);
return token;
+ } catch (ExpiredJwtException ex) {
+ throw new TokenExpiredException("The jwt token has been expired", ex);
} catch (JwtException ex) {
throw new AuthenticationException("signature is invalid", ex);
}
@@ -92,5 +96,5 @@ public final class JwtAccessTokenResolver implements AccessTokenResolver {
private String createValidationFailedMessage(AccessTokenValidator validator, AccessToken accessToken) {
return String.format("token %s is invalid, marked by validator %s", accessToken.getId(), validator.getClass());
}
-
+
}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java
new file mode 100644
index 0000000000..9799eac57e
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultGPG.java
@@ -0,0 +1,121 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.apache.shiro.SecurityUtils;
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPKeyRingGenerator;
+import org.bouncycastle.openpgp.PGPObjectFactory;
+import org.bouncycastle.openpgp.PGPSignatureList;
+import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sonia.scm.security.GPG;
+import sonia.scm.security.PrivateKey;
+import sonia.scm.security.PublicKey;
+
+import javax.inject.Inject;
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.util.Collections;
+import java.util.List;
+import java.util.Optional;
+import java.util.stream.Collectors;
+
+public class DefaultGPG implements GPG {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DefaultGPG.class);
+
+ private final PublicKeyStore publicKeyStore;
+ private final PrivateKeyStore privateKeyStore;
+
+ @Inject
+ public DefaultGPG(PublicKeyStore publicKeyStore, PrivateKeyStore privateKeyStore) {
+ this.publicKeyStore = publicKeyStore;
+ this.privateKeyStore = privateKeyStore;
+ }
+
+ @Override
+ public String findPublicKeyId(byte[] signature) {
+ try {
+ ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature));
+ PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
+ PGPSignatureList signatures = (PGPSignatureList) pgpObjectFactory.nextObject();
+ return "0x" + Long.toHexString(signatures.get(0).getKeyID()).toUpperCase();
+ } catch (IOException e) {
+ LOG.error("Could not find public key id in signature");
+ }
+ return "";
+ }
+
+ @Override
+ public Optional findPublicKey(String id) {
+ Optional key = publicKeyStore.findById(id);
+
+ return key.map(RawGpgKeyToDefaultPublicKeyMapper::map);
+ }
+
+ @Override
+ public Iterable findPublicKeysByUsername(String username) {
+ List keys = publicKeyStore.findByUsername(username);
+
+ if (!keys.isEmpty()) {
+ return keys
+ .stream()
+ .map(rawGpgKey -> new DefaultPublicKey(rawGpgKey.getId(), rawGpgKey.getOwner(), rawGpgKey.getRaw(), rawGpgKey.getContacts()))
+ .collect(Collectors.toSet());
+ }
+
+ return Collections.emptySet();
+ }
+
+ @Override
+ public PrivateKey getPrivateKey() {
+ final String userId = SecurityUtils.getSubject().getPrincipal().toString();
+ final Optional privateRawKey = privateKeyStore.getForUserId(userId);
+
+ if (!privateRawKey.isPresent()) {
+ try {
+ final PGPKeyRingGenerator keyPair = GPGKeyPairGenerator.generateKeyPair();
+
+ final String rawPublicKey = GPGKeyExporter.exportKeyRing(keyPair.generatePublicKeyRing());
+ final String rawPrivateKey = GPGKeyExporter.exportKeyRing(keyPair.generateSecretKeyRing());
+
+ privateKeyStore.setForUserId(userId, rawPrivateKey);
+ publicKeyStore.add("Default SCM-Manager Signing Key", userId, rawPublicKey, true);
+
+ return DefaultPrivateKey.parseRaw(rawPrivateKey);
+ } catch (PGPException | NoSuchAlgorithmException | NoSuchProviderException | IOException e) {
+ throw new GPGException("Private key could not be generated", e);
+ }
+ } else {
+ return DefaultPrivateKey.parseRaw(privateRawKey.get());
+ }
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPrivateKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPrivateKey.java
new file mode 100644
index 0000000000..f925dee108
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPrivateKey.java
@@ -0,0 +1,87 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.apache.commons.io.IOUtils;
+import org.bouncycastle.bcpg.ArmoredOutputStream;
+import org.bouncycastle.bcpg.BCPGOutputStream;
+import org.bouncycastle.bcpg.HashAlgorithmTags;
+import org.bouncycastle.bcpg.PublicKeyAlgorithmTags;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.PGPSignatureGenerator;
+import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
+import sonia.scm.security.PrivateKey;
+
+import javax.validation.constraints.NotNull;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+class DefaultPrivateKey implements PrivateKey {
+
+ static DefaultPrivateKey parseRaw(String rawPrivateKey) {
+ return new DefaultPrivateKey(KeysExtractor.extractPrivateKey(rawPrivateKey));
+ }
+
+ private final PGPPrivateKey privateKey;
+
+ private DefaultPrivateKey(@NotNull PGPPrivateKey privateKey) {
+ this.privateKey = privateKey;
+ }
+
+ @Override
+ public String getId() {
+ return Keys.createId(privateKey);
+ }
+
+ @Override
+ public byte[] sign(InputStream stream) {
+
+ PGPSignatureGenerator signatureGenerator = new PGPSignatureGenerator(
+ new JcaPGPContentSignerBuilder(
+ PublicKeyAlgorithmTags.RSA_GENERAL,
+ HashAlgorithmTags.SHA1).setProvider(BouncyCastleProvider.PROVIDER_NAME)
+ );
+
+ try {
+ signatureGenerator.init(PGPSignature.BINARY_DOCUMENT, privateKey);
+ } catch (PGPException e) {
+ throw new GPGException("Could not initialize signature generator", e);
+ }
+
+ ByteArrayOutputStream buffer = new ByteArrayOutputStream();
+ try (BCPGOutputStream out = new BCPGOutputStream(new ArmoredOutputStream(buffer))) {
+ signatureGenerator.update(IOUtils.toByteArray(stream));
+ signatureGenerator.generate().encode(out);
+ } catch (PGPException | IOException e) {
+ throw new GPGException("Could not create signature", e);
+ }
+
+ return buffer.toByteArray();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java
new file mode 100644
index 0000000000..f4853c86b7
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DefaultPublicKey.java
@@ -0,0 +1,147 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.bouncycastle.openpgp.PGPCompressedData;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPObjectFactory;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.PGPSignatureList;
+import org.bouncycastle.openpgp.PGPUtil;
+import org.bouncycastle.openpgp.jcajce.JcaPGPObjectFactory;
+import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentVerifierBuilderProvider;
+import org.slf4j.Logger;
+import org.slf4j.LoggerFactory;
+import sonia.scm.repository.Person;
+import sonia.scm.security.PublicKey;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.Optional;
+import java.util.Set;
+
+public class DefaultPublicKey implements PublicKey {
+
+ private static final Logger LOG = LoggerFactory.getLogger(DefaultPublicKey.class);
+
+ private final String id;
+ private final String owner;
+ private final String raw;
+ private final Set contacts;
+
+ public DefaultPublicKey(String id, String owner, String raw, Set contacts) {
+ this.id = id;
+ this.owner = owner;
+ this.raw = raw;
+ this.contacts = contacts;
+ }
+
+ @Override
+ public String getId() {
+ return id;
+ }
+
+ @Override
+ public Optional getOwner() {
+ if (owner == null) {
+ return Optional.empty();
+ }
+ return Optional.of(owner);
+ }
+
+ @Override
+ public String getRaw() {
+ return raw;
+ }
+
+ @Override
+ public Set getContacts() {
+ return contacts;
+ }
+
+ @Override
+ public boolean verify(InputStream stream, byte[] signature) {
+ boolean verified = false;
+ try {
+ verified = verify(stream, asDecodedStream(signature));
+ } catch (IOException | PGPException e) {
+ LOG.error("Could not verify GPG key", e);
+ }
+
+ return verified;
+ }
+
+ private boolean verify(InputStream stream, InputStream signature) throws IOException, PGPException {
+ PGPObjectFactory pgpObjectFactory = new JcaPGPObjectFactory(signature);
+ Object o = pgpObjectFactory.nextObject();
+ if (o instanceof PGPSignatureList) {
+ return verify(stream, ((PGPSignatureList) o).get(0));
+ } else if (o instanceof PGPCompressedData) {
+ return verify(stream, ((PGPCompressedData) o).getDataStream());
+ } else {
+ LOG.warn("could not find valid signature, only found {}", o);
+ return false;
+ }
+ }
+
+ private boolean verify(InputStream stream, PGPSignature signature) throws IOException, PGPException {
+ PGPPublicKey publicKey = findKey(signature);
+ if (publicKey != null) {
+ JcaPGPContentVerifierBuilderProvider provider = new JcaPGPContentVerifierBuilderProvider();
+ signature.init(provider, publicKey);
+
+ int bytesRead;
+ byte[] buffer = new byte[1024];
+ while ((bytesRead = stream.read(buffer, 0, buffer.length)) != -1) {
+ signature.update(buffer, 0, bytesRead);
+ }
+
+ return signature.verify();
+ } else {
+ LOG.warn("failed to parse public gpg key");
+ }
+ return false;
+ }
+
+ private PGPPublicKey findKey(PGPSignature signature) throws IOException {
+ PGPObjectFactory pgpObjectFactory = new JcaPGPObjectFactory(asDecodedStream(raw));
+ PGPPublicKeyRing keyRing = (PGPPublicKeyRing) pgpObjectFactory.nextObject();
+ return keyRing.getPublicKey(signature.getKeyID());
+ }
+
+ private InputStream asDecodedStream(String content) throws IOException {
+ return asDecodedStream(content.getBytes(StandardCharsets.US_ASCII));
+ }
+
+ private InputStream asDecodedStream(byte[] bytes) throws IOException {
+ return PGPUtil.getDecoderStream(new ByteArrayInputStream(bytes));
+ }
+}
+
+
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/DeletingReadonlyKeyNotAllowedException.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/DeletingReadonlyKeyNotAllowedException.java
new file mode 100644
index 0000000000..b559801116
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/DeletingReadonlyKeyNotAllowedException.java
@@ -0,0 +1,43 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import sonia.scm.BadRequestException;
+import sonia.scm.ContextEntry;
+
+@SuppressWarnings("squid:MaximumInheritanceDepth") // exceptions have a deep inheritance depth themselves; therefore we accept this here
+public final class DeletingReadonlyKeyNotAllowedException extends BadRequestException {
+
+ public DeletingReadonlyKeyNotAllowedException(String keyId) {
+ super(ContextEntry.ContextBuilder.entity(RawGpgKey.class, keyId).build(), "deleting readonly gpg keys is not allowed");
+ }
+
+ private static final String CODE = "3US6mweXy1";
+
+ @Override
+ public String getCode() {
+ return CODE;
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGException.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGException.java
new file mode 100644
index 0000000000..da278f03da
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGException.java
@@ -0,0 +1,35 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+public class GPGException extends RuntimeException {
+ GPGException(String message) {
+ super(message);
+ }
+
+ GPGException(String message, Throwable cause) {
+ super(message, cause);
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGKeyExporter.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGKeyExporter.java
new file mode 100644
index 0000000000..15a21daf89
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGKeyExporter.java
@@ -0,0 +1,43 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.bouncycastle.bcpg.ArmoredOutputStream;
+import org.bouncycastle.openpgp.PGPKeyRing;
+
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+
+class GPGKeyExporter {
+ private GPGKeyExporter() { }
+
+ static String exportKeyRing(PGPKeyRing keyRing) throws IOException {
+ final ByteArrayOutputStream byteArrayOutputStream = new ByteArrayOutputStream();
+ final ArmoredOutputStream armoredOutputStream = new ArmoredOutputStream(byteArrayOutputStream);
+ keyRing.encode(armoredOutputStream);
+ armoredOutputStream.close();
+ return new String(byteArrayOutputStream.toByteArray());
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGKeyPairGenerator.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGKeyPairGenerator.java
new file mode 100644
index 0000000000..9532ca3fae
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGKeyPairGenerator.java
@@ -0,0 +1,72 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.apache.shiro.SecurityUtils;
+import org.bouncycastle.bcpg.HashAlgorithmTags;
+import org.bouncycastle.bcpg.PublicKeyAlgorithmTags;
+import org.bouncycastle.bcpg.SymmetricKeyAlgorithmTags;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPKeyPair;
+import org.bouncycastle.openpgp.PGPKeyRingGenerator;
+import org.bouncycastle.openpgp.PGPSignature;
+import org.bouncycastle.openpgp.operator.jcajce.JcaPGPContentSignerBuilder;
+import org.bouncycastle.openpgp.operator.jcajce.JcaPGPDigestCalculatorProviderBuilder;
+import org.bouncycastle.openpgp.operator.jcajce.JcaPGPKeyPair;
+import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyEncryptorBuilder;
+import sonia.scm.repository.Person;
+import sonia.scm.user.User;
+
+import java.security.KeyPair;
+import java.security.KeyPairGenerator;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.util.Date;
+
+final class GPGKeyPairGenerator {
+ private GPGKeyPairGenerator() {}
+
+ static PGPKeyRingGenerator generateKeyPair() throws PGPException, NoSuchProviderException, NoSuchAlgorithmException {
+ KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
+ keyPairGenerator.initialize(2048);
+
+ KeyPair pair = keyPairGenerator.generateKeyPair();
+
+ PGPKeyPair keyPair = new JcaPGPKeyPair(PublicKeyAlgorithmTags.RSA_GENERAL, pair, new Date());
+ final User user = SecurityUtils.getSubject().getPrincipals().oneByType(User.class);
+ final Person person = new Person(user.getDisplayName(), user.getMail());
+
+ return new PGPKeyRingGenerator(
+ PGPSignature.POSITIVE_CERTIFICATION,
+ keyPair,
+ person.toString(),
+ new JcaPGPDigestCalculatorProviderBuilder().build().get(HashAlgorithmTags.SHA1),
+ null,
+ null,
+ new JcaPGPContentSignerBuilder(PublicKeyAlgorithmTags.RSA_GENERAL, HashAlgorithmTags.SHA1),
+ new JcePBESecretKeyEncryptorBuilder(SymmetricKeyAlgorithmTags.AES_256).build(new char[]{})
+ );
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java
new file mode 100644
index 0000000000..7087a971a4
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/GPGModule.java
@@ -0,0 +1,37 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.inject.AbstractModule;
+import sonia.scm.plugin.Extension;
+import sonia.scm.security.GPG;
+
+@Extension
+public class GPGModule extends AbstractModule {
+ @Override
+ protected void configure() {
+ bind(GPG.class).to(DefaultGPG.class);
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java
new file mode 100644
index 0000000000..a03819b923
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/Keys.java
@@ -0,0 +1,117 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import lombok.Value;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPPublicKeyRingCollection;
+import org.bouncycastle.openpgp.PGPUtil;
+import org.bouncycastle.openpgp.operator.KeyFingerPrintCalculator;
+import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+import java.nio.charset.StandardCharsets;
+import java.util.ArrayList;
+import java.util.Collections;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Locale;
+import java.util.Set;
+import java.util.function.Function;
+
+@Value
+final class Keys {
+
+ private static final KeyFingerPrintCalculator calculator = new JcaKeyFingerprintCalculator();
+
+ private final String master;
+ private final Set subs;
+
+ private Keys(String master, Set subs) {
+ this.master = master;
+ this.subs = subs;
+ }
+
+ static Keys resolve(String raw) {
+ return resolve(raw, Keys::collectKeys);
+ }
+
+ static Keys resolve(String raw, Function> parser) {
+ List parsedKeys = parser.apply(raw);
+
+ String master = null;
+ Set subs = new HashSet<>();
+
+ for (PGPPublicKey key : parsedKeys) {
+ if (key.isMasterKey()) {
+ if (master != null) {
+ throw new IllegalArgumentException("Found more than one master key");
+ }
+ master = createId(key);
+ } else {
+ subs.add(createId(key));
+ }
+ }
+
+ if (master == null) {
+ throw new IllegalArgumentException("No master key found");
+ }
+
+ return new Keys(master, Collections.unmodifiableSet(subs));
+ }
+
+ static String createId(PGPPublicKey pgpPublicKey) {
+ return formatKey(pgpPublicKey.getKeyID());
+ }
+
+ static String createId(PGPPrivateKey pgpPrivateKey) {
+ return formatKey(pgpPrivateKey.getKeyID());
+ }
+
+ static String formatKey(long keyId) {
+ return "0x" + Long.toHexString(keyId).toUpperCase(Locale.ENGLISH);
+ }
+
+ private static List collectKeys(String rawKey) {
+ try {
+ List publicKeys = new ArrayList<>();
+ InputStream decoderStream = PGPUtil.getDecoderStream(new ByteArrayInputStream(rawKey.getBytes(StandardCharsets.UTF_8)));
+ PGPPublicKeyRingCollection collection = new PGPPublicKeyRingCollection(decoderStream, calculator);
+ for (PGPPublicKeyRing pgpPublicKeys : collection) {
+ for (PGPPublicKey pgpPublicKey : pgpPublicKeys) {
+ publicKeys.add(pgpPublicKey);
+ }
+ }
+ return publicKeys;
+ } catch (IOException | PGPException ex) {
+ throw new GPGException("Failed to collect public keys", ex);
+ }
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/KeysExtractor.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/KeysExtractor.java
new file mode 100644
index 0000000000..aee1aa371e
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/KeysExtractor.java
@@ -0,0 +1,63 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.bouncycastle.bcpg.ArmoredInputStream;
+import org.bouncycastle.openpgp.PGPObjectFactory;
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.bouncycastle.openpgp.PGPPublicKeyRing;
+import org.bouncycastle.openpgp.PGPUtil;
+import org.bouncycastle.openpgp.jcajce.JcaPGPSecretKeyRingCollection;
+import org.bouncycastle.openpgp.operator.jcajce.JcaKeyFingerprintCalculator;
+import org.bouncycastle.openpgp.operator.jcajce.JcePBESecretKeyDecryptorBuilder;
+
+import java.io.ByteArrayInputStream;
+import java.io.IOException;
+import java.io.InputStream;
+
+final class KeysExtractor {
+
+ private KeysExtractor() {}
+
+ static PGPPrivateKey extractPrivateKey(String rawKey) {
+ try (final InputStream decoderStream = PGPUtil.getDecoderStream(new ByteArrayInputStream(rawKey.getBytes()))) {
+ JcaPGPSecretKeyRingCollection secretKeyRingCollection = new JcaPGPSecretKeyRingCollection(decoderStream);
+ return secretKeyRingCollection.getKeyRings().next().getSecretKey().extractPrivateKey(new JcePBESecretKeyDecryptorBuilder().build(new char[]{}));
+ } catch (Exception e) {
+ throw new GPGException("Invalid PGP key", e);
+ }
+ }
+
+ static PGPPublicKey extractPublicKey(String rawKey) {
+ try {
+ ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(rawKey.getBytes()));
+ PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
+ return ((PGPPublicKeyRing) pgpObjectFactory.nextObject()).getPublicKey();
+ } catch (IOException e) {
+ throw new GPGException("Invalid PGP key", e);
+ }
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/MasterKeyReference.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/MasterKeyReference.java
new file mode 100644
index 0000000000..86e5b1df38
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/MasterKeyReference.java
@@ -0,0 +1,42 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement
+public class MasterKeyReference {
+ String masterKey;
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java
new file mode 100644
index 0000000000..94962b4f37
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PrivateKeyStore.java
@@ -0,0 +1,77 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import sonia.scm.security.CipherUtil;
+import sonia.scm.store.DataStore;
+import sonia.scm.store.DataStoreFactory;
+import sonia.scm.xml.XmlInstantAdapter;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.time.Instant;
+import java.util.Optional;
+
+@Singleton
+class PrivateKeyStore {
+
+ private static final String STORE_NAME = "gpg_private_keys";
+
+ private final DataStore store;
+
+ @Inject
+ PrivateKeyStore(DataStoreFactory dataStoreFactory) {
+ this.store = dataStoreFactory.withType(RawPrivateKey.class).withName(STORE_NAME).build();
+ }
+
+ Optional getForUserId(String userId) {
+ return store.getOptional(userId).map(rawPrivateKey -> CipherUtil.getInstance().decode(rawPrivateKey.key));
+ }
+
+ void setForUserId(String userId, String rawKey) {
+ final String encodedRawKey = CipherUtil.getInstance().encode(rawKey);
+ store.put(userId, new RawPrivateKey(encodedRawKey, Instant.now()));
+ }
+
+ @XmlRootElement
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @AllArgsConstructor
+ @NoArgsConstructor
+ @Getter
+ static class RawPrivateKey {
+ private String key;
+
+ @XmlJavaTypeAdapter(XmlInstantAdapter.class)
+ private Instant date;
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java
new file mode 100644
index 0000000000..c8d46be647
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyCollectionMapper.java
@@ -0,0 +1,86 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import de.otto.edison.hal.Embedded;
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Link;
+import de.otto.edison.hal.Links;
+import sonia.scm.api.v2.resources.LinkBuilder;
+import sonia.scm.api.v2.resources.ScmPathInfoStore;
+import sonia.scm.user.UserPermissions;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import static de.otto.edison.hal.Links.linkingTo;
+
+public class PublicKeyCollectionMapper {
+
+ private final Provider scmPathInfoStore;
+ private final PublicKeyMapper mapper;
+
+ @Inject
+ public PublicKeyCollectionMapper(Provider scmPathInfoStore, PublicKeyMapper mapper) {
+ this.scmPathInfoStore = scmPathInfoStore;
+ this.mapper = mapper;
+ }
+
+ HalRepresentation map(String username, List keys) {
+ List dtos = keys.stream()
+ .map(mapper::map)
+ .collect(Collectors.toList());
+
+ Links.Builder builder = linkingTo();
+
+ builder.self(selfLink(username));
+
+ if (hasCreatePermissions(username)) {
+ builder.single(Link.link("create", createLink(username)));
+ }
+
+ return new HalRepresentation(builder.build(), Embedded.embedded("keys", dtos));
+ }
+
+ private boolean hasCreatePermissions(String username) {
+ return UserPermissions.changePublicKeys(username).isPermitted();
+ }
+
+ private String createLink(String username) {
+ return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class)
+ .method("create")
+ .parameters(username)
+ .href();
+ }
+
+ private String selfLink(String username) {
+ return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class)
+ .method("findAll")
+ .parameters(username)
+ .href();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java
new file mode 100644
index 0000000000..81604153f6
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyMapper.java
@@ -0,0 +1,88 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.common.annotations.VisibleForTesting;
+import de.otto.edison.hal.Link;
+import de.otto.edison.hal.Links;
+import org.mapstruct.Mapper;
+import org.mapstruct.Mapping;
+import org.mapstruct.ObjectFactory;
+import sonia.scm.api.v2.resources.LinkBuilder;
+import sonia.scm.api.v2.resources.ScmPathInfoStore;
+import sonia.scm.user.UserPermissions;
+
+import javax.inject.Inject;
+import javax.inject.Provider;
+
+import static de.otto.edison.hal.Links.linkingTo;
+
+@Mapper
+public abstract class PublicKeyMapper {
+
+ @Inject
+ private Provider scmPathInfoStore;
+
+ @VisibleForTesting
+ void setScmPathInfoStore(Provider scmPathInfoStore) {
+ this.scmPathInfoStore = scmPathInfoStore;
+ }
+
+ @Mapping(target = "attributes", ignore = true)
+ @Mapping(target = "raw", ignore = true)
+ abstract RawGpgKeyDto map(RawGpgKey rawGpgKey);
+
+ @ObjectFactory
+ RawGpgKeyDto createDto(RawGpgKey rawGpgKey) {
+ Links.Builder linksBuilder = linkingTo();
+ linksBuilder.self(createSelfLink(rawGpgKey));
+ if (UserPermissions.changePublicKeys(rawGpgKey.getOwner()).isPermitted() && !rawGpgKey.isReadonly()) {
+ linksBuilder.single(Link.link("delete", createDeleteLink(rawGpgKey)));
+ }
+ linksBuilder.single(Link.link("raw", createDownloadLink(rawGpgKey)));
+ return new RawGpgKeyDto(linksBuilder.build());
+ }
+
+ private String createSelfLink(RawGpgKey rawGpgKey) {
+ return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class)
+ .method("findByIdJson")
+ .parameters(rawGpgKey.getOwner(), rawGpgKey.getId())
+ .href();
+ }
+
+ private String createDeleteLink(RawGpgKey rawGpgKey) {
+ return new LinkBuilder(scmPathInfoStore.get().get(), UserPublicKeyResource.class)
+ .method("deleteById")
+ .parameters(rawGpgKey.getOwner(), rawGpgKey.getId())
+ .href();
+ }
+
+ private String createDownloadLink(RawGpgKey rawGpgKey) {
+ return new LinkBuilder(scmPathInfoStore.get().get(), PublicKeyResource.class)
+ .method("findByIdGpg")
+ .parameters(rawGpgKey.getId())
+ .href();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java
new file mode 100644
index 0000000000..6af6fbb4e6
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyResource.java
@@ -0,0 +1,99 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import sonia.scm.api.v2.resources.ErrorDto;
+import sonia.scm.security.AllowAnonymousAccess;
+import sonia.scm.web.VndMediaType;
+
+import javax.inject.Inject;
+import javax.ws.rs.GET;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Response;
+import java.util.Optional;
+
+@Path("v2/public_keys")
+public class PublicKeyResource {
+
+
+ private final PublicKeyStore store;
+
+ @Inject
+ public PublicKeyResource(PublicKeyStore store) {
+ this.store = store;
+ }
+
+ @GET
+ @Path("{id}")
+ @Produces("application/pgp-keys")
+ @AllowAnonymousAccess
+ @Operation(
+ summary = "Get single key for user",
+ description = "Returns a single public key for username by id.",
+ tags = "User",
+ operationId = "get_single_public_key"
+ )
+ @ApiResponse(
+ responseCode = "200",
+ description = "success",
+ content = @Content(
+ mediaType = "application/pgp-keys"
+ )
+ )
+ @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
+ @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
+ @ApiResponse(
+ responseCode = "404",
+ description = "not found / key for given id not available",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ @ApiResponse(
+ responseCode = "500",
+ description = "internal server error",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ public Response findByIdGpg(@PathParam("id") String id) {
+ Optional byId = store.findById(id);
+ if (byId.isPresent()) {
+ return Response.ok(byId.get().getRaw())
+ .header("Content-Disposition", "attachment; filename=\"" + byId.get().getDisplayName() + ".asc\"")
+ .build();
+ }
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java
new file mode 100644
index 0000000000..d271f41769
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/PublicKeyStore.java
@@ -0,0 +1,131 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.bouncycastle.openpgp.PGPPublicKey;
+import sonia.scm.ContextEntry;
+import sonia.scm.event.ScmEventBus;
+import sonia.scm.repository.Person;
+import sonia.scm.security.NotPublicKeyException;
+import sonia.scm.security.PublicKeyCreatedEvent;
+import sonia.scm.security.PublicKeyDeletedEvent;
+import sonia.scm.store.DataStore;
+import sonia.scm.store.DataStoreFactory;
+import sonia.scm.user.UserPermissions;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+import java.time.Instant;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+import java.util.Set;
+import java.util.stream.Collectors;
+
+import static sonia.scm.security.gpg.KeysExtractor.extractPublicKey;
+
+@Singleton
+public class PublicKeyStore {
+
+ private static final String STORE_NAME = "gpg_public_keys";
+ private static final String SUBKEY_STORE_NAME = "gpg_public_sub_keys";
+
+ private final DataStore store;
+ private final DataStore subKeyStore;
+ private final ScmEventBus eventBus;
+
+ @Inject
+ public PublicKeyStore(DataStoreFactory dataStoreFactory, ScmEventBus eventBus) {
+ this.store = dataStoreFactory.withType(RawGpgKey.class).withName(STORE_NAME).build();
+ this.subKeyStore = dataStoreFactory.withType(MasterKeyReference.class).withName(SUBKEY_STORE_NAME).build();
+ this.eventBus = eventBus;
+ }
+
+ public RawGpgKey add(String displayName, String username, String rawKey) {
+ return add(displayName, username, rawKey, false);
+ }
+
+ public RawGpgKey add(String displayName, String username, String rawKey, boolean readonly) {
+ UserPermissions.changePublicKeys(username).check();
+
+ if (!rawKey.contains("PUBLIC KEY")) {
+ throw new NotPublicKeyException(ContextEntry.ContextBuilder.entity(RawGpgKey.class, displayName).build(), "The provided key is not a public key");
+ }
+
+ Keys keys = Keys.resolve(rawKey);
+ String master = keys.getMaster();
+
+ for (String subKey : keys.getSubs()) {
+ subKeyStore.put(subKey, new MasterKeyReference(master));
+ }
+
+ RawGpgKey key = new RawGpgKey(master, displayName, username, rawKey, getContactsFromPublicKey(rawKey), Instant.now(), readonly);
+
+ store.put(master, key);
+ eventBus.post(new PublicKeyCreatedEvent(RawGpgKeyToDefaultPublicKeyMapper.map(key)));
+
+ return key;
+
+ }
+
+ private Set getContactsFromPublicKey(String rawKey) {
+ List userIds = new ArrayList<>();
+ PGPPublicKey publicKeyFromRawKey = extractPublicKey(rawKey);
+ publicKeyFromRawKey.getUserIDs().forEachRemaining(userIds::add);
+
+ return userIds.stream().map(Person::toPerson).collect(Collectors.toSet());
+ }
+
+ public void delete(String id) {
+ RawGpgKey rawGpgKey = store.get(id);
+ if (rawGpgKey != null) {
+ if (!rawGpgKey.isReadonly()) {
+ UserPermissions.changePublicKeys(rawGpgKey.getOwner()).check();
+ store.remove(id);
+ eventBus.post(new PublicKeyDeletedEvent(RawGpgKeyToDefaultPublicKeyMapper.map(rawGpgKey)));
+ } else {
+ throw new DeletingReadonlyKeyNotAllowedException(id);
+ }
+ }
+ }
+
+ public Optional findById(String id) {
+ Optional reference = subKeyStore.getOptional(id);
+
+ if (reference.isPresent()) {
+ return store.getOptional(reference.get().getMasterKey());
+ }
+
+ return store.getOptional(id);
+ }
+
+ public List findByUsername(String username) {
+ return store.getAll().values()
+ .stream()
+ .filter(rawGpgKey -> username.equalsIgnoreCase(rawGpgKey.getOwner()))
+ .collect(Collectors.toList());
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java
new file mode 100644
index 0000000000..1e02222310
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKey.java
@@ -0,0 +1,92 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import lombok.AllArgsConstructor;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import sonia.scm.repository.Person;
+import sonia.scm.xml.XmlInstantAdapter;
+
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+import javax.xml.bind.annotation.adapters.XmlJavaTypeAdapter;
+import java.time.Instant;
+import java.util.Objects;
+import java.util.Set;
+
+@Getter
+@NoArgsConstructor
+@AllArgsConstructor
+@XmlAccessorType(XmlAccessType.FIELD)
+@XmlRootElement
+public class RawGpgKey {
+
+ private String id;
+ private String displayName;
+ private String owner;
+ private String raw;
+ private Set contacts;
+
+ @XmlJavaTypeAdapter(XmlInstantAdapter.class)
+ private Instant created;
+
+ private boolean readonly;
+
+ RawGpgKey(String id) {
+ this.id = id;
+ }
+ RawGpgKey(String id, String raw) {
+ this.id = id;
+ this.raw = raw;
+ }
+ RawGpgKey(String id, String displayName, String owner, String raw, Set contacts, Instant created) {
+ this.id = id;
+ this.displayName = displayName;
+ this.owner = owner;
+ this.contacts = contacts;
+ this.created = created;
+ this.raw = raw;
+ }
+
+ @Override
+ public boolean equals(Object o) {
+ if (this == o) {
+ return true;
+ }
+ if (o == null || getClass() != o.getClass()) {
+ return false;
+ }
+ RawGpgKey that = (RawGpgKey) o;
+ return Objects.equals(id, that.id);
+ }
+
+ @Override
+ public int hashCode() {
+ return Objects.hash(id);
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKeyDto.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKeyDto.java
new file mode 100644
index 0000000000..b56df8f726
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKeyDto.java
@@ -0,0 +1,49 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import de.otto.edison.hal.HalRepresentation;
+import de.otto.edison.hal.Links;
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import lombok.Setter;
+
+import java.time.Instant;
+
+@Getter
+@Setter
+@NoArgsConstructor
+@SuppressWarnings("squid:S2160") // we do not need equals for dto
+public class RawGpgKeyDto extends HalRepresentation {
+
+ private String id;
+ private String displayName;
+ private String raw;
+ private Instant created;
+
+ RawGpgKeyDto(Links links) {
+ super(links);
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKeyToDefaultPublicKeyMapper.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKeyToDefaultPublicKeyMapper.java
new file mode 100644
index 0000000000..7b07092857
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/RawGpgKeyToDefaultPublicKeyMapper.java
@@ -0,0 +1,37 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import sonia.scm.security.PublicKey;
+
+final class RawGpgKeyToDefaultPublicKeyMapper {
+
+ private RawGpgKeyToDefaultPublicKeyMapper() {}
+
+ static PublicKey map(RawGpgKey key) {
+ return new DefaultPublicKey(key.getId(), key.getOwner(), key.getRaw(), key.getContacts());
+ }
+
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java b/scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java
new file mode 100644
index 0000000000..c7fbb38709
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/security/gpg/UserPublicKeyResource.java
@@ -0,0 +1,190 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import de.otto.edison.hal.HalRepresentation;
+import io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.media.Content;
+import io.swagger.v3.oas.annotations.media.Schema;
+import io.swagger.v3.oas.annotations.responses.ApiResponse;
+import sonia.scm.api.v2.resources.ErrorDto;
+import sonia.scm.web.VndMediaType;
+
+import javax.inject.Inject;
+import javax.ws.rs.Consumes;
+import javax.ws.rs.DELETE;
+import javax.ws.rs.GET;
+import javax.ws.rs.POST;
+import javax.ws.rs.Path;
+import javax.ws.rs.PathParam;
+import javax.ws.rs.Produces;
+import javax.ws.rs.core.Context;
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
+import java.util.Optional;
+
+@Path("v2/users/{username}/public_keys")
+public class UserPublicKeyResource {
+
+ private static final String MEDIA_TYPE_COLLECTION = VndMediaType.PREFIX + "publicKeyCollection" + VndMediaType.SUFFIX;
+ private static final String MEDIA_TYPE = VndMediaType.PREFIX + "publicKey" + VndMediaType.SUFFIX;
+
+ private final PublicKeyCollectionMapper collectionMapper;
+ private final PublicKeyStore store;
+ private final PublicKeyMapper mapper;
+
+ @Inject
+ public UserPublicKeyResource(PublicKeyCollectionMapper collectionMapper, PublicKeyMapper mapper, PublicKeyStore store) {
+ this.collectionMapper = collectionMapper;
+ this.store = store;
+ this.mapper = mapper;
+ }
+
+ @GET
+ @Path("")
+ @Produces(MEDIA_TYPE_COLLECTION)
+ @Operation(
+ summary = "Get all public keys for user",
+ description = "Returns all keys for the given username.",
+ tags = "User",
+ operationId = "get_all_public_keys"
+ )
+ @ApiResponse(
+ responseCode = "200",
+ description = "success",
+ content = @Content(
+ mediaType = MEDIA_TYPE_COLLECTION,
+ schema = @Schema(implementation = HalRepresentation.class)
+ )
+ )
+ @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
+ @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
+ @ApiResponse(
+ responseCode = "500",
+ description = "internal server error",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ public HalRepresentation findAll(@PathParam("username") String id) {
+ return collectionMapper.map(id, store.findByUsername(id));
+ }
+
+ @POST
+ @Path("")
+ @Consumes(MEDIA_TYPE)
+ @Operation(
+ summary = "Create new key",
+ description = "Creates new key for user.",
+ tags = "User",
+ operationId = "create_public_key"
+ )
+ @ApiResponse(responseCode = "201", description = "create success")
+ @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
+ @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
+ @ApiResponse(
+ responseCode = "500",
+ description = "internal server error",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ public Response create(@Context UriInfo uriInfo, @PathParam("username") String username, RawGpgKeyDto publicKey) {
+ String id = store.add(publicKey.getDisplayName(), username, publicKey.getRaw()).getId();
+ UriBuilder builder = uriInfo.getAbsolutePathBuilder();
+ builder.path(id);
+ return Response.created(builder.build()).build();
+ }
+
+ @DELETE
+ @Path("{id}")
+ @Operation(
+ summary = "Deletes public key",
+ description = "Deletes public key for user.",
+ tags = "User",
+ operationId = "delete_public_key"
+ )
+ @ApiResponse(responseCode = "204", description = "delete success")
+ @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
+ @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
+ @ApiResponse(
+ responseCode = "500",
+ description = "internal server error",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ public Response deleteById(@PathParam("id") String id) {
+ store.delete(id);
+ return Response.noContent().build();
+ }
+
+ @GET
+ @Path("{id}")
+ @Produces(MEDIA_TYPE)
+ @Operation(
+ summary = "Get single key for user",
+ description = "Returns a single public key for username by id.",
+ tags = "User",
+ operationId = "get_single_public_key"
+ )
+ @ApiResponse(
+ responseCode = "200",
+ description = "success",
+ content = @Content(
+ mediaType = MEDIA_TYPE,
+ schema = @Schema(implementation = RawGpgKeyDto.class)
+ )
+ )
+ @ApiResponse(responseCode = "401", description = "not authenticated / invalid credentials")
+ @ApiResponse(responseCode = "403", description = "not authorized / the current user does not have the right privilege")
+ @ApiResponse(
+ responseCode = "404",
+ description = "not found / key for given id not available",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ @ApiResponse(
+ responseCode = "500",
+ description = "internal server error",
+ content = @Content(
+ mediaType = VndMediaType.ERROR_TYPE,
+ schema = @Schema(implementation = ErrorDto.class)
+ )
+ )
+ public Response findByIdJson(@PathParam("id") String id) {
+ Optional byId = store.findById(id);
+ if (byId.isPresent()) {
+ return Response.ok(mapper.map(byId.get())).build();
+ }
+ return Response.status(Response.Status.NOT_FOUND).build();
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/update/repository/AnonymousModeUpdateStep.java b/scm-webapp/src/main/java/sonia/scm/update/repository/AnonymousModeUpdateStep.java
new file mode 100644
index 0000000000..dccc096912
--- /dev/null
+++ b/scm-webapp/src/main/java/sonia/scm/update/repository/AnonymousModeUpdateStep.java
@@ -0,0 +1,104 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.update.repository;
+
+import lombok.Getter;
+import lombok.NoArgsConstructor;
+import sonia.scm.SCMContextProvider;
+import sonia.scm.config.ScmConfiguration;
+import sonia.scm.migration.UpdateStep;
+import sonia.scm.plugin.Extension;
+import sonia.scm.security.AnonymousMode;
+import sonia.scm.store.ConfigurationStore;
+import sonia.scm.store.ConfigurationStoreFactory;
+import sonia.scm.store.StoreConstants;
+import sonia.scm.version.Version;
+
+import javax.inject.Inject;
+import javax.xml.bind.JAXBContext;
+import javax.xml.bind.JAXBException;
+import javax.xml.bind.annotation.XmlAccessType;
+import javax.xml.bind.annotation.XmlAccessorType;
+import javax.xml.bind.annotation.XmlRootElement;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static sonia.scm.version.Version.parse;
+
+@Extension
+public class AnonymousModeUpdateStep implements UpdateStep {
+
+ private final SCMContextProvider contextProvider;
+ private final ConfigurationStore configStore;
+
+ @Inject
+ public AnonymousModeUpdateStep(SCMContextProvider contextProvider, ConfigurationStoreFactory configurationStoreFactory) {
+ this.contextProvider = contextProvider;
+ this.configStore = configurationStoreFactory.withType(ScmConfiguration.class).withName("config").build();
+ }
+
+ @Override
+ public void doUpdate() throws JAXBException {
+ Path configFile = determineConfigDirectory().resolve("config" + StoreConstants.FILE_EXTENSION);
+
+ if (configFile.toFile().exists()) {
+ PreUpdateScmConfiguration oldConfig = getPreUpdateScmConfigurationFromOldConfig(configFile);
+ ScmConfiguration config = configStore.get();
+ if (oldConfig.isAnonymousAccessEnabled()) {
+ config.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
+ } else {
+ config.setAnonymousMode(AnonymousMode.OFF);
+ }
+ configStore.set(config);
+ }
+ }
+
+ @Override
+ public Version getTargetVersion() {
+ return parse("2.4.0");
+ }
+
+ @Override
+ public String getAffectedDataType() {
+ return "config.xml";
+ }
+
+ private PreUpdateScmConfiguration getPreUpdateScmConfigurationFromOldConfig(Path configFile) throws JAXBException {
+ JAXBContext jaxbContext = JAXBContext.newInstance(AnonymousModeUpdateStep.PreUpdateScmConfiguration.class);
+ return (AnonymousModeUpdateStep.PreUpdateScmConfiguration) jaxbContext.createUnmarshaller().unmarshal(configFile.toFile());
+ }
+
+ private Path determineConfigDirectory() {
+ return contextProvider.resolve(Paths.get(StoreConstants.CONFIG_DIRECTORY_NAME));
+ }
+
+ @XmlRootElement(name = "scm-config")
+ @XmlAccessorType(XmlAccessType.FIELD)
+ @NoArgsConstructor
+ @Getter
+ static class PreUpdateScmConfiguration {
+ private boolean anonymousAccessEnabled;
+ }
+}
diff --git a/scm-webapp/src/main/java/sonia/scm/user/AnonymousUserDeletionEventHandler.java b/scm-webapp/src/main/java/sonia/scm/user/AnonymousUserDeletionEventHandler.java
index 5ff6dc02b5..343239729f 100644
--- a/scm-webapp/src/main/java/sonia/scm/user/AnonymousUserDeletionEventHandler.java
+++ b/scm-webapp/src/main/java/sonia/scm/user/AnonymousUserDeletionEventHandler.java
@@ -31,6 +31,7 @@ import sonia.scm.HandlerEventType;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
import sonia.scm.plugin.Extension;
+import sonia.scm.security.AnonymousMode;
import javax.inject.Inject;
@@ -38,7 +39,7 @@ import javax.inject.Inject;
@Extension
public class AnonymousUserDeletionEventHandler {
- private ScmConfiguration scmConfiguration;
+ private final ScmConfiguration scmConfiguration;
@Inject
public AnonymousUserDeletionEventHandler(ScmConfiguration scmConfiguration) {
@@ -55,6 +56,6 @@ public class AnonymousUserDeletionEventHandler {
private boolean isAnonymousUserDeletionNotAllowed(UserEvent event) {
return event.getEventType() == HandlerEventType.BEFORE_DELETE
&& event.getItem().getName().equals(SCMContext.USER_ANONYMOUS)
- && scmConfiguration.isAnonymousAccessEnabled();
+ && scmConfiguration.getAnonymousMode() != AnonymousMode.OFF;
}
}
diff --git a/scm-webapp/src/main/resources/locales/de/plugins.json b/scm-webapp/src/main/resources/locales/de/plugins.json
index ebb15d7038..169c38cd85 100644
--- a/scm-webapp/src/main/resources/locales/de/plugins.json
+++ b/scm-webapp/src/main/resources/locales/de/plugins.json
@@ -260,6 +260,14 @@
"3tS0mjSoo1": {
"displayName": "Fehler bei der Erstellung eines Arbeitsverzeichnisses",
"description": "Der Server konnte kein Arbeitsverzeichnis zur Abarbeitung der Anfrage erstellen. Bitte prüfen Sie die Server Logs für genauere Informationen."
+ },
+ "3US6mweXy1": {
+ "displayName": "Fehler beim Löschen eines schreibgeschützen Schlüssels",
+ "description": "Vom Server generierte, öffentliche Schlüssel sind schreibgeschützt und können nicht gelöscht werden."
+ },
+ "BxS5wX2v71": {
+ "displayName": "Inkorrekter Schlüssel",
+ "description": "Der bereitgestellte Schlüssel ist kein korrekt formartierter öffentlicher Schlüssel."
}
},
"namespaceStrategies": {
diff --git a/scm-webapp/src/main/resources/locales/en/plugins.json b/scm-webapp/src/main/resources/locales/en/plugins.json
index 5cb6ae09df..c65c506386 100644
--- a/scm-webapp/src/main/resources/locales/en/plugins.json
+++ b/scm-webapp/src/main/resources/locales/en/plugins.json
@@ -260,6 +260,14 @@
"3tS0mjSoo1": {
"displayName": "Error creating a new working directory",
"description": "The server could not create a new working directory to process the request. Please check the server log for further information."
+ },
+ "3US6mweXy1": {
+ "displayName": "Error deleting readonly key",
+ "description": "Public keys generated by the server are readonly and cannot be deleted."
+ },
+ "BxS5wX2v71": {
+ "displayName": "Invalid key",
+ "description": "The provided key is not a valid public key."
}
},
"namespaceStrategies": {
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java
index 9991575103..92ab7b1209 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/AuthenticationResourceTest.java
@@ -67,7 +67,7 @@ public class AuthenticationResourceTest {
@Rule
public ShiroRule shiro = new ShiroRule();
- private RestDispatcher dispatcher = new RestDispatcher();
+ private final RestDispatcher dispatcher = new RestDispatcher();
@Mock
private AccessTokenBuilderFactory accessTokenBuilderFactory;
@@ -75,9 +75,9 @@ public class AuthenticationResourceTest {
@Mock
private AccessTokenBuilder accessTokenBuilder;
- private AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class));
+ private final AccessTokenCookieIssuer cookieIssuer = new DefaultAccessTokenCookieIssuer(mock(ScmConfiguration.class));
- private MockHttpResponse response = new MockHttpResponse();
+ private final MockHttpResponse response = new MockHttpResponse();
private static final String AUTH_JSON_TRILLIAN = "{\n" +
"\t\"cookie\": true,\n" +
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java
index 5f24384452..3e01a1e4cc 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ConfigDtoToScmConfigurationMapperTest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import org.junit.Before;
@@ -29,6 +29,7 @@ import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.internal.util.collections.Sets;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import java.util.Arrays;
@@ -41,9 +42,9 @@ public class ConfigDtoToScmConfigurationMapperTest {
@InjectMocks
private ConfigDtoToScmConfigurationMapperImpl mapper;
- private String[] expectedUsers = { "trillian", "arthur" };
- private String[] expectedGroups = { "admin", "plebs" };
- private String[] expectedExcludes = { "ex", "clude" };
+ private String[] expectedUsers = {"trillian", "arthur"};
+ private String[] expectedGroups = {"admin", "plebs"};
+ private String[] expectedExcludes = {"ex", "clude"};
@Before
public void init() {
@@ -55,27 +56,42 @@ public class ConfigDtoToScmConfigurationMapperTest {
ConfigDto dto = createDefaultDto();
ScmConfiguration config = mapper.map(dto);
- assertEquals("prPw" , config.getProxyPassword());
- assertEquals(42 , config.getProxyPort());
- assertEquals("srvr" , config.getProxyServer());
- assertEquals("user" , config.getProxyUser());
+ assertEquals("prPw", config.getProxyPassword());
+ assertEquals(42, config.getProxyPort());
+ assertEquals("srvr", config.getProxyServer());
+ assertEquals("user", config.getProxyUser());
assertTrue(config.isEnableProxy());
- assertEquals("realm" , config.getRealmDescription());
+ assertEquals("realm", config.getRealmDescription());
assertTrue(config.isDisableGroupingGrid());
- assertEquals("yyyy" , config.getDateFormat());
- assertTrue(config.isAnonymousAccessEnabled());
- assertEquals("baseurl" , config.getBaseUrl());
+ assertEquals("yyyy", config.getDateFormat());
+ assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
+ assertEquals("baseurl", config.getBaseUrl());
assertTrue(config.isForceBaseUrl());
- assertEquals(41 , config.getLoginAttemptLimit());
+ assertEquals(41, config.getLoginAttemptLimit());
assertTrue("proxyExcludes", config.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
assertTrue(config.isSkipFailedAuthenticators());
- assertEquals("https://plug.ins" , config.getPluginUrl());
- assertEquals(40 , config.getLoginAttemptLimitTimeout());
+ assertEquals("https://plug.ins", config.getPluginUrl());
+ assertEquals(40, config.getLoginAttemptLimitTimeout());
assertTrue(config.isEnabledXsrfProtection());
assertEquals("username", config.getNamespaceStrategy());
assertEquals("https://scm-manager.org/login-info", config.getLoginInfoUrl());
}
+ @Test
+ public void shouldMapAnonymousAccessFieldToAnonymousMode() {
+ ConfigDto dto = createDefaultDto();
+
+ ScmConfiguration config = mapper.map(dto);
+
+ assertEquals(AnonymousMode.PROTOCOL_ONLY, config.getAnonymousMode());
+
+ dto.setAnonymousMode(null);
+ dto.setAnonymousAccessEnabled(false);
+ ScmConfiguration config2 = mapper.map(dto);
+
+ assertEquals(AnonymousMode.OFF, config2.getAnonymousMode());
+ }
+
private ConfigDto createDefaultDto() {
ConfigDto configDto = new ConfigDto();
configDto.setProxyPassword("prPw");
@@ -86,7 +102,7 @@ public class ConfigDtoToScmConfigurationMapperTest {
configDto.setRealmDescription("realm");
configDto.setDisableGroupingGrid(true);
configDto.setDateFormat("yyyy");
- configDto.setAnonymousAccessEnabled(true);
+ configDto.setAnonymousMode(AnonymousMode.PROTOCOL_ONLY);
configDto.setBaseUrl("baseurl");
configDto.setForceBaseUrl(true);
configDto.setLoginAttemptLimit(41);
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java
new file mode 100644
index 0000000000..97c9714513
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/IndexDtoGeneratorTest.java
@@ -0,0 +1,137 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.api.v2.resources;
+
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.BasicContextProvider;
+import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
+
+import java.net.URI;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+import static sonia.scm.SCMContext.USER_ANONYMOUS;
+
+@ExtendWith(MockitoExtension.class)
+class IndexDtoGeneratorTest {
+
+ private static final ScmPathInfo scmPathInfo = () -> URI.create("/api/v2");
+
+ @Mock
+ private ScmConfiguration configuration;
+ @Mock
+ private BasicContextProvider contextProvider;
+ @Mock
+ private ResourceLinks resourceLinks;
+
+ @Mock
+ private Subject subject;
+
+ @InjectMocks
+ private IndexDtoGenerator generator;
+
+ @BeforeEach
+ void bindSubject() {
+ ThreadContext.bind(subject);
+ }
+
+ @AfterEach
+ void tearDownSubject() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Test
+ void shouldAppendMeIfAuthenticated() {
+ mockSubjectRelatedResourceLinks();
+ when(subject.isAuthenticated()).thenReturn(true);
+
+ when(contextProvider.getVersion()).thenReturn("2.x");
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isPresent();
+ }
+
+ @Test
+ void shouldNotAppendMeIfUserIsAuthenticatedButAnonymous() {
+ mockResourceLinks();
+ when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
+ when(subject.isAuthenticated()).thenReturn(true);
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
+ }
+
+ @Test
+ void shouldAppendMeIfUserIsAnonymousAndAnonymousModeIsFullEnabled() {
+ mockSubjectRelatedResourceLinks();
+ when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
+ when(subject.isAuthenticated()).thenReturn(true);
+ when(configuration.getAnonymousMode()).thenReturn(AnonymousMode.FULL);
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isPresent();
+ }
+
+ @Test
+ void shouldNotAppendMeIfUserIsAnonymousAndAnonymousModeIsProtocolOnly() {
+ mockResourceLinks();
+ when(subject.getPrincipal()).thenReturn(USER_ANONYMOUS);
+ when(subject.isAuthenticated()).thenReturn(true);
+ when(configuration.getAnonymousMode()).thenReturn(AnonymousMode.PROTOCOL_ONLY);
+
+ IndexDto dto = generator.generate();
+
+ assertThat(dto.getLinks().getLinkBy("me")).isNotPresent();
+ }
+
+
+ private void mockResourceLinks() {
+ when(resourceLinks.index()).thenReturn(new ResourceLinks.IndexLinks(scmPathInfo));
+ when(resourceLinks.uiPluginCollection()).thenReturn(new ResourceLinks.UIPluginCollectionLinks(scmPathInfo));
+ when(resourceLinks.authentication()).thenReturn(new ResourceLinks.AuthenticationLinks(scmPathInfo));
+ }
+
+ private void mockSubjectRelatedResourceLinks() {
+ mockResourceLinks();
+ when(resourceLinks.repositoryCollection()).thenReturn(new ResourceLinks.RepositoryCollectionLinks(scmPathInfo));
+ when(resourceLinks.repositoryVerbs()).thenReturn(new ResourceLinks.RepositoryVerbLinks(scmPathInfo));
+ when(resourceLinks.repositoryTypeCollection()).thenReturn(new ResourceLinks.RepositoryTypeCollectionLinks(scmPathInfo));
+ when(resourceLinks.repositoryRoleCollection()).thenReturn(new ResourceLinks.RepositoryRoleCollectionLinks(scmPathInfo));
+ when(resourceLinks.namespaceStrategies()).thenReturn(new ResourceLinks.NamespaceStrategiesLinks(scmPathInfo));
+ when(resourceLinks.me()).thenReturn(new ResourceLinks.MeLinks(scmPathInfo, new ResourceLinks.UserLinks(scmPathInfo)));
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java
index cffd6dc840..901d625579 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/MeDtoFactoryTest.java
@@ -187,15 +187,37 @@ class MeDtoFactoryTest {
}
@Test
- void shouldNotGetPasswordLinkForAnonymousUser() {
+ void shouldAppendOnlySelfLinkIfAnonymousUser() {
User user = SCMContext.ANONYMOUS;
prepareSubject(user);
- when(userManager.isTypeDefault(any())).thenReturn(true);
- when(UserPermissions.changePassword(user).isPermitted()).thenReturn(true);
+ MeDto dto = meDtoFactory.create();
+ assertThat(dto.getLinks().getLinkBy("self")).isPresent();
+ assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
+ assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
+ assertThat(dto.getLinks().getLinkBy("update")).isNotPresent();
+ }
+
+ @Test
+ void shouldAppendPublicKeysLink() {
+ User user = UserTestData.createTrillian();
+ prepareSubject(user);
+
+ when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
MeDto dto = meDtoFactory.create();
- assertThat(dto.getLinks().getLinkBy("password")).isNotPresent();
+ assertThat(dto.getLinks().getLinkBy("publicKeys").get().getHref()).isEqualTo("https://scm.hitchhiker.com/scm/v2/users/trillian/public_keys");
+ }
+
+ @Test
+ void shouldNotAppendPublicKeysLink() {
+ User user = UserTestData.createTrillian();
+ prepareSubject(user);
+
+ when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(false);
+
+ MeDto dto = meDtoFactory.create();
+ assertThat(dto.getLinks().getLinkBy("publicKeys")).isNotPresent();
}
@Test
@@ -213,6 +235,4 @@ class MeDtoFactoryTest {
MeDto dto = meDtoFactory.create();
assertThat(dto.getLinks().getLinkBy("profile").get().getHref()).isEqualTo("http://hitchhiker.com/users/trillian");
}
-
-
}
diff --git a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java
index 28394348a1..40b6bb837e 100644
--- a/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/api/v2/resources/ScmConfigurationToConfigDtoMapperTest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.api.v2.resources;
import org.apache.shiro.subject.Subject;
@@ -34,12 +34,14 @@ import org.junit.Test;
import org.mockito.InjectMocks;
import org.mockito.internal.util.collections.Sets;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import java.net.URI;
import java.util.Arrays;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
+import static org.junit.Assert.assertSame;
import static org.junit.Assert.assertTrue;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
@@ -47,11 +49,11 @@ import static org.mockito.MockitoAnnotations.initMocks;
public class ScmConfigurationToConfigDtoMapperTest {
- private URI baseUri = URI.create("http://example.com/base/");
+ private URI baseUri = URI.create("http://example.com/base/");
- private String[] expectedUsers = { "trillian", "arthur" };
- private String[] expectedGroups = { "admin", "plebs" };
- private String[] expectedExcludes = { "ex", "clude" };
+ private String[] expectedUsers = {"trillian", "arthur"};
+ private String[] expectedGroups = {"admin", "plebs"};
+ private String[] expectedExcludes = {"ex", "clude"};
@SuppressWarnings("unused") // Is injected
private ResourceLinks resourceLinks = ResourceLinksMock.createMock(baseUri);
@@ -85,22 +87,22 @@ public class ScmConfigurationToConfigDtoMapperTest {
when(subject.isPermitted("configuration:write:global")).thenReturn(true);
ConfigDto dto = mapper.map(config);
- assertEquals("heartOfGold" , dto.getProxyPassword());
- assertEquals(1234 , dto.getProxyPort());
- assertEquals("proxyserver" , dto.getProxyServer());
- assertEquals("trillian" , dto.getProxyUser());
+ assertEquals("heartOfGold", dto.getProxyPassword());
+ assertEquals(1234, dto.getProxyPort());
+ assertEquals("proxyserver", dto.getProxyServer());
+ assertEquals("trillian", dto.getProxyUser());
assertTrue(dto.isEnableProxy());
- assertEquals("description" , dto.getRealmDescription());
+ assertEquals("description", dto.getRealmDescription());
assertTrue(dto.isDisableGroupingGrid());
- assertEquals("dd" , dto.getDateFormat());
- assertTrue(dto.isAnonymousAccessEnabled());
- assertEquals("baseurl" , dto.getBaseUrl());
+ assertEquals("dd", dto.getDateFormat());
+ assertSame(AnonymousMode.FULL, dto.getAnonymousMode());
+ assertEquals("baseurl", dto.getBaseUrl());
assertTrue(dto.isForceBaseUrl());
- assertEquals(1 , dto.getLoginAttemptLimit());
+ assertEquals(1, dto.getLoginAttemptLimit());
assertTrue("proxyExcludes", dto.getProxyExcludes().containsAll(Arrays.asList(expectedExcludes)));
assertTrue(dto.isSkipFailedAuthenticators());
- assertEquals("pluginurl" , dto.getPluginUrl());
- assertEquals(2 , dto.getLoginAttemptLimitTimeout());
+ assertEquals("pluginurl", dto.getPluginUrl());
+ assertEquals(2, dto.getLoginAttemptLimitTimeout());
assertTrue(dto.isEnabledXsrfProtection());
assertEquals("username", dto.getNamespaceStrategy());
assertEquals("https://scm-manager.org/login-info", dto.getLoginInfoUrl());
@@ -121,6 +123,21 @@ public class ScmConfigurationToConfigDtoMapperTest {
assertFalse(dto.getLinks().hasLink("update"));
}
+ @Test
+ public void shouldMapAnonymousAccessField() {
+ ScmConfiguration config = createConfiguration();
+
+ when(subject.hasRole("configuration:write:global")).thenReturn(false);
+ ConfigDto dto = mapper.map(config);
+
+ assertTrue(dto.isAnonymousAccessEnabled());
+
+ config.setAnonymousMode(AnonymousMode.OFF);
+ ConfigDto secondDto = mapper.map(config);
+
+ assertFalse(secondDto.isAnonymousAccessEnabled());
+ }
+
private ScmConfiguration createConfiguration() {
ScmConfiguration config = new ScmConfiguration();
config.setProxyPassword("heartOfGold");
@@ -131,7 +148,7 @@ public class ScmConfigurationToConfigDtoMapperTest {
config.setRealmDescription("description");
config.setDisableGroupingGrid(true);
config.setDateFormat("dd");
- config.setAnonymousAccessEnabled(true);
+ config.setAnonymousMode(AnonymousMode.FULL);
config.setBaseUrl("baseurl");
config.setForceBaseUrl(true);
config.setLoginAttemptLimit(1);
diff --git a/scm-webapp/src/test/java/sonia/scm/filter/PropagatePrincipleFilterTest.java b/scm-webapp/src/test/java/sonia/scm/filter/PropagatePrincipleFilterTest.java
index 6403489e40..6071ec32dd 100644
--- a/scm-webapp/src/test/java/sonia/scm/filter/PropagatePrincipleFilterTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/filter/PropagatePrincipleFilterTest.java
@@ -38,6 +38,7 @@ import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.user.User;
import sonia.scm.user.UserTestData;
@@ -110,7 +111,7 @@ public class PropagatePrincipleFilterTest {
*/
@Test
public void testAnonymousWithAccessEnabled() throws IOException, ServletException {
- configuration.setAnonymousAccessEnabled(true);
+ configuration.setAnonymousMode(AnonymousMode.FULL);
// execute
propagatePrincipleFilter.doFilter(request, response, chain);
diff --git a/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java b/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java
index ff276cd1fa..fab7b789ad 100644
--- a/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/lifecycle/SetupContextListenerTest.java
@@ -37,6 +37,7 @@ import org.mockito.junit.jupiter.MockitoSettings;
import org.mockito.quality.Strictness;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import sonia.scm.security.PermissionAssigner;
import sonia.scm.security.PermissionDescriptor;
import sonia.scm.user.User;
@@ -82,7 +83,7 @@ class SetupContextListenerTest {
@BeforeEach
void mockScmConfiguration() {
- when(scmConfiguration.isAnonymousAccessEnabled()).thenReturn(false);
+ when(scmConfiguration.getAnonymousMode()).thenReturn(AnonymousMode.OFF);
}
@BeforeEach
@@ -145,7 +146,7 @@ class SetupContextListenerTest {
void shouldCreateAnonymousUserIfRequired() {
List users = Lists.newArrayList(UserTestData.createTrillian());
when(userManager.getAll()).thenReturn(users);
- when(scmConfiguration.isAnonymousAccessEnabled()).thenReturn(true);
+ when(scmConfiguration.getAnonymousMode()).thenReturn(AnonymousMode.FULL);
setupContextListener.contextInitialized(null);
@@ -166,7 +167,7 @@ class SetupContextListenerTest {
void shouldNotCreateAnonymousUserIfAlreadyExists() {
List users = Lists.newArrayList(SCMContext.ANONYMOUS);
when(userManager.getAll()).thenReturn(users);
- when(scmConfiguration.isAnonymousAccessEnabled()).thenReturn(true);
+ when(scmConfiguration.getAnonymousMode()).thenReturn(AnonymousMode.FULL);
setupContextListener.contextInitialized(null);
diff --git a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java
index db5ba7b200..c0ac82b7f6 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/BearerRealmTest.java
@@ -21,7 +21,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE.
*/
-
+
package sonia.scm.security;
import org.apache.shiro.authc.AuthenticationInfo;
diff --git a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java
index e05904e723..15d4419952 100644
--- a/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/security/JwtAccessTokenResolverTest.java
@@ -130,7 +130,7 @@ public class JwtAccessTokenResolverTest {
String compact = createCompactToken("trillian", secureKey, exp, Scope.empty());
// expect exception
- expectedException.expect(AuthenticationException.class);
+ expectedException.expect(TokenExpiredException.class);
expectedException.expectCause(instanceOf(ExpiredJwtException.class));
BearerToken bearer = BearerToken.valueOf(compact);
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java
new file mode 100644
index 0000000000..c8817a4d81
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultGPGTest.java
@@ -0,0 +1,195 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ImmutableSet;
+import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.mgt.DefaultSecurityManager;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPKeyRingGenerator;
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.repository.Person;
+import sonia.scm.security.PrivateKey;
+import sonia.scm.security.PublicKey;
+import sonia.scm.util.MockUtil;
+
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.Security;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.anyString;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.atLeastOnce;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class DefaultGPGTest {
+
+ private static void registerBouncyCastleProviderIfNecessary() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ @Mock
+ private PublicKeyStore publicKeyStore;
+
+ @Mock
+ private PrivateKeyStore privateKeyStore;
+
+ @InjectMocks
+ private DefaultGPG gpg;
+
+ private Subject subjectUnderTest;
+
+ @AfterEach
+ void unbindThreadContext() {
+ ThreadContext.unbindSubject();
+ ThreadContext.unbindSecurityManager();
+ }
+
+ @BeforeEach
+ void bindThreadContext() {
+ registerBouncyCastleProviderIfNecessary();
+
+ SecurityUtils.setSecurityManager(new DefaultSecurityManager());
+ subjectUnderTest = MockUtil.createUserSubject(SecurityUtils.getSecurityManager());
+ ThreadContext.bind(subjectUnderTest);
+ }
+
+ @Test
+ void shouldFindIdInSignature() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("slarti.txt.asc");
+ String publicKeyId = gpg.findPublicKeyId(raw.getBytes());
+
+ assertThat(publicKeyId).isEqualTo("0x247E908C6FD35473");
+ }
+
+ @Test
+ void shouldFindPublicKey() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("subkeys.asc");
+ Person trillian = Person.toPerson("Trillian ");
+ RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", raw, ImmutableSet.of(trillian), Instant.now());
+
+ when(publicKeyStore.findById("42")).thenReturn(Optional.of(key1));
+
+ Optional publicKey = gpg.findPublicKey("42");
+
+ assertThat(publicKey).isPresent();
+ assertThat(publicKey.get().getOwner()).isPresent();
+ assertThat(publicKey.get().getOwner().get()).contains("trillian");
+ assertThat(publicKey.get().getId()).isEqualTo("42");
+ assertThat(publicKey.get().getContacts()).contains(trillian);
+ }
+
+ @Test
+ void shouldFindKeysForUsername() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+ String raw2= GPGTestHelper.readResourceAsString("subkeys.asc");
+
+ RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", raw, Collections.emptySet(), Instant.now());
+ RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", raw2, Collections.emptySet(), Instant.now());
+ when(publicKeyStore.findByUsername("trillian")).thenReturn(ImmutableList.of(key1, key2));
+
+ Iterable keys = gpg.findPublicKeysByUsername("trillian");
+
+ assertThat(keys).hasSize(2);
+ PublicKey key = keys.iterator().next();
+ assertThat(key.getOwner()).isPresent();
+ assertThat(key.getOwner().get()).contains("trillian");
+ }
+
+ @Test
+ void shouldImportExportedGeneratedPrivateKey() throws NoSuchProviderException, NoSuchAlgorithmException, PGPException, IOException {
+ final PGPKeyRingGenerator keyRingGenerator = GPGKeyPairGenerator.generateKeyPair();
+ final String exportedPrivateKey = GPGKeyExporter.exportKeyRing(keyRingGenerator.generateSecretKeyRing());
+ final PGPPrivateKey privateKey = KeysExtractor.extractPrivateKey(exportedPrivateKey);
+ assertThat(privateKey).isNotNull();
+ }
+
+ @Test
+ void shouldCreateSignature() throws IOException {
+ SecurityUtils.setSecurityManager(new DefaultSecurityManager());
+ Subject subjectUnderTest = MockUtil.createUserSubject(SecurityUtils.getSecurityManager());
+ ThreadContext.bind(subjectUnderTest);
+
+ String raw = GPGTestHelper.readResourceAsString("private-key.asc");
+ final DefaultPrivateKey privateKey = DefaultPrivateKey.parseRaw(raw);
+ final byte[] signature = privateKey.sign("This is a test commit".getBytes());
+ final String signatureString = new String(signature);
+ assertThat(signature).isNotEmpty();
+ assertThat(signatureString)
+ .startsWith("-----BEGIN PGP SIGNATURE-----")
+ .contains("-----END PGP SIGNATURE-----");
+ }
+
+ @Test
+ void shouldReturnGeneratedPrivateKeyIfNoneStored() {
+ SecurityUtils.setSecurityManager(new DefaultSecurityManager());
+ Subject subjectUnderTest = MockUtil.createUserSubject(SecurityUtils.getSecurityManager());
+ ThreadContext.bind(subjectUnderTest);
+
+ final PrivateKey privateKey = gpg.getPrivateKey();
+ assertThat(privateKey).isNotNull();
+
+ verify(privateKeyStore, atLeastOnce()).setForUserId(eq(subjectUnderTest.getPrincipal().toString()), anyString());
+ verify(publicKeyStore, atLeastOnce()).add(eq("Default SCM-Manager Signing Key"), eq(subjectUnderTest.getPrincipal().toString()), anyString(), eq(true));
+ }
+
+ @Test
+ void shouldReturnStoredPrivateKey() throws IOException {
+ SecurityUtils.setSecurityManager(new DefaultSecurityManager());
+ Subject subjectUnderTest = MockUtil.createUserSubject(SecurityUtils.getSecurityManager());
+ ThreadContext.bind(subjectUnderTest);
+
+ String raw = GPGTestHelper.readResourceAsString("private-key.asc");
+ when(privateKeyStore.getForUserId(subjectUnderTest.getPrincipal().toString())).thenReturn(Optional.of(raw));
+
+ final PrivateKey privateKey = gpg.getPrivateKey();
+ assertThat(privateKey).isNotNull();
+ verify(privateKeyStore, never()).setForUserId(eq(subjectUnderTest.getPrincipal().toString()), anyString());
+ verify(publicKeyStore, never()).add(eq("Default SCM-Manager Signing Key"), eq(subjectUnderTest.getPrincipal().toString()), anyString(), eq(true));
+
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyTest.java
new file mode 100644
index 0000000000..39faf3bef2
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/DefaultPublicKeyTest.java
@@ -0,0 +1,48 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class DefaultPublicKeyTest {
+
+ @Test
+ void shouldVerifyPublicKey() throws IOException {
+ String rawPublicKey = GPGTestHelper.readResourceAsString("subkeys.asc");
+ DefaultPublicKey publicKey = new DefaultPublicKey("1", "trillian", rawPublicKey, Collections.emptySet());
+
+ byte[] content = GPGTestHelper.readResourceAsBytes("slarti.txt");
+ byte[] signature = GPGTestHelper.readResourceAsBytes("slarti.txt.asc");
+
+ boolean verified = publicKey.verify(content, signature);
+ assertThat(verified).isTrue();
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGKeyExporterTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGKeyExporterTest.java
new file mode 100644
index 0000000000..dccf40bd07
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGKeyExporterTest.java
@@ -0,0 +1,85 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.mgt.DefaultSecurityManager;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPKeyRingGenerator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import sonia.scm.util.MockUtil;
+
+import java.io.IOException;
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.Security;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class GPGKeyExporterTest {
+
+ private static void registerBouncyCastleProviderIfNecessary() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ @AfterEach
+ void unbindThreadContext() {
+ ThreadContext.unbindSubject();
+ ThreadContext.unbindSecurityManager();
+ }
+
+ @BeforeEach
+ void bindThreadContext() {
+ registerBouncyCastleProviderIfNecessary();
+
+ SecurityUtils.setSecurityManager(new DefaultSecurityManager());
+ ThreadContext.bind(MockUtil.createUserSubject(SecurityUtils.getSecurityManager()));
+ }
+
+ @Test
+ void shouldExportGeneratedKeyPair() throws NoSuchProviderException, NoSuchAlgorithmException, PGPException, IOException {
+ final PGPKeyRingGenerator keyRingGenerator = GPGKeyPairGenerator.generateKeyPair();
+
+ final String exportedPublicKey = GPGKeyExporter.exportKeyRing(keyRingGenerator.generatePublicKeyRing());
+ assertThat(exportedPublicKey)
+ .isNotBlank()
+ .startsWith("-----BEGIN PGP PUBLIC KEY BLOCK-----")
+ .contains("-----END PGP PUBLIC KEY BLOCK-----");
+
+ final String exportedPrivateKey = GPGKeyExporter.exportKeyRing(keyRingGenerator.generateSecretKeyRing());
+ assertThat(exportedPrivateKey)
+ .isNotBlank()
+ .startsWith("-----BEGIN PGP PRIVATE KEY BLOCK-----")
+ .contains("-----END PGP PRIVATE KEY BLOCK-----");
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGKeyPairGeneratorTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGKeyPairGeneratorTest.java
new file mode 100644
index 0000000000..e535dc26e9
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGKeyPairGeneratorTest.java
@@ -0,0 +1,73 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.apache.shiro.SecurityUtils;
+import org.apache.shiro.mgt.DefaultSecurityManager;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.assertj.core.api.Assertions;
+import org.bouncycastle.jce.provider.BouncyCastleProvider;
+import org.bouncycastle.openpgp.PGPException;
+import org.bouncycastle.openpgp.PGPKeyRingGenerator;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import sonia.scm.util.MockUtil;
+
+import java.security.NoSuchAlgorithmException;
+import java.security.NoSuchProviderException;
+import java.security.Security;
+
+class GPGKeyPairGeneratorTest {
+
+ private static void registerBouncyCastleProviderIfNecessary() {
+ if (Security.getProvider(BouncyCastleProvider.PROVIDER_NAME) == null) {
+ Security.addProvider(new BouncyCastleProvider());
+ }
+ }
+
+ @AfterEach
+ void unbindThreadContext() {
+ ThreadContext.unbindSubject();
+ ThreadContext.unbindSecurityManager();
+ }
+
+ @BeforeEach
+ void bindThreadContext() {
+ registerBouncyCastleProviderIfNecessary();
+
+ SecurityUtils.setSecurityManager(new DefaultSecurityManager());
+ ThreadContext.bind(MockUtil.createUserSubject(SecurityUtils.getSecurityManager()));
+ }
+
+ @Test
+ void shouldGenerateKeyPair() throws NoSuchProviderException, NoSuchAlgorithmException, PGPException {
+ final PGPKeyRingGenerator keyRingGenerator = GPGKeyPairGenerator.generateKeyPair();
+ Assertions.assertThat(keyRingGenerator.generatePublicKeyRing().getPublicKey()).isNotNull();
+ Assertions.assertThat(keyRingGenerator.generateSecretKeyRing().getSecretKey()).isNotNull();
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGTestHelper.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGTestHelper.java
new file mode 100644
index 0000000000..b295139a65
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/GPGTestHelper.java
@@ -0,0 +1,50 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.common.io.Resources;
+
+import java.io.IOException;
+import java.net.URL;
+import java.nio.charset.StandardCharsets;
+
+final class GPGTestHelper {
+
+ private GPGTestHelper() {
+ }
+
+ @SuppressWarnings("UnstableApiUsage")
+ static byte[] readResourceAsBytes(String fileName) throws IOException {
+ URL resource = Resources.getResource("sonia/scm/security/gpg/" + fileName);
+ return Resources.toByteArray(resource);
+ }
+
+ @SuppressWarnings("UnstableApiUsage")
+ static String readResourceAsString(String fileName) throws IOException {
+ URL resource = Resources.getResource("sonia/scm/security/gpg/" + fileName);
+ return Resources.toString(resource, StandardCharsets.UTF_8);
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysExtractorTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysExtractorTest.java
new file mode 100644
index 0000000000..dcd38a9b88
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysExtractorTest.java
@@ -0,0 +1,54 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.bouncycastle.openpgp.PGPPrivateKey;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.junit.jupiter.api.Test;
+
+import java.io.IOException;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+class KeysExtractorTest {
+
+ @Test
+ void shouldExtractPublicKeyFromRawKey() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+
+ PGPPublicKey publicKey = KeysExtractor.extractPublicKey(raw);
+
+ assertThat(publicKey).isNotNull();
+ assertThat(Long.toHexString(publicKey.getKeyID())).isEqualTo("975922f193b07d6e");
+ }
+
+ @Test
+ void shouldExtractPrivateKeyFromRawKey() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("private-key.asc");
+ final PGPPrivateKey privateKey = KeysExtractor.extractPrivateKey(raw);
+ assertThat(privateKey).isNotNull();
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java
new file mode 100644
index 0000000000..b78a0c5795
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/KeysTest.java
@@ -0,0 +1,81 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.common.collect.ImmutableList;
+import org.bouncycastle.openpgp.PGPPublicKey;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import java.io.IOException;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.Mockito.lenient;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+import static sonia.scm.security.gpg.GPGTestHelper.readResourceAsString;
+
+@ExtendWith(MockitoExtension.class)
+class KeysTest {
+
+ @Test
+ void shouldResolveSingleId() throws IOException {
+ String rawPublicKey = readResourceAsString("single.asc");
+ Keys keys = Keys.resolve(rawPublicKey);
+ assertThat(keys.getMaster()).isEqualTo("0x975922F193B07D6E");
+ }
+
+ @Test
+ void shouldResolveIdsFromSubkeys() throws IOException {
+ String rawPublicKey = readResourceAsString("subkeys.asc");
+ Keys keys = Keys.resolve(rawPublicKey);
+ assertThat(keys.getMaster()).isEqualTo("0x13B13D4C8A9350A1");
+ assertThat(keys.getSubs()).containsOnly("0x247E908C6FD35473", "0xE50E1DD8B90D3A6B", "0xBF49759E43DD0E60");
+ }
+
+ @Test
+ void shouldThrowIllegalArgumentExceptionForMultipleMasterKeys() {
+ PGPPublicKey one = mockMasterKey(42L);
+ PGPPublicKey two = mockMasterKey(21L);
+
+ assertThrows(IllegalArgumentException.class, () -> Keys.resolve("", raw -> ImmutableList.of(one, two)));
+ }
+
+ @Test
+ void shouldThrowIllegalArgumentExceptionWithoutMasterKey() {
+ assertThrows(IllegalArgumentException.class, () -> Keys.resolve("", raw -> Collections.emptyList()));
+ }
+
+ private PGPPublicKey mockMasterKey(long id) {
+ PGPPublicKey key = mock(PGPPublicKey.class);
+ when(key.isMasterKey()).thenReturn(true);
+ lenient().when(key.getKeyID()).thenReturn(id);
+ return key;
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java
new file mode 100644
index 0000000000..d9744024e1
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PrivateKeyStoreTest.java
@@ -0,0 +1,63 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.store.DataStoreFactory;
+import sonia.scm.store.InMemoryDataStoreFactory;
+
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+@ExtendWith(MockitoExtension.class)
+class PrivateKeyStoreTest {
+
+
+ private DataStoreFactory dataStoreFactory;
+ private PrivateKeyStore keyStore;
+
+ @BeforeEach
+ void setup() {
+ dataStoreFactory = new InMemoryDataStoreFactory();
+ keyStore = new PrivateKeyStore(dataStoreFactory);
+ }
+
+ @Test
+ void returnEmptyIfNotYetSet() {
+ final Optional rawKey = keyStore.getForUserId("testId");
+ assertThat(rawKey).isEmpty();
+ }
+
+ @Test
+ void setForUserId() {
+ keyStore.setForUserId("testId", "Test Key");
+ final Optional rawKey = keyStore.getForUserId("testId");
+ assertThat(rawKey).contains("Test Key");
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyCollectionMapperTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyCollectionMapperTest.java
new file mode 100644
index 0000000000..bb8ec5e5ad
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyCollectionMapperTest.java
@@ -0,0 +1,111 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.common.collect.Lists;
+import com.google.inject.util.Providers;
+import de.otto.edison.hal.HalRepresentation;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.api.v2.resources.ScmPathInfoStore;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Collections;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PublicKeyCollectionMapperTest {
+
+
+ private PublicKeyCollectionMapper collectionMapper;
+
+ @Mock
+ private PublicKeyMapper mapper;
+
+ @Mock
+ private Subject subject;
+
+ @BeforeEach
+ void setUpObjectUnderTest() {
+ ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
+ pathInfoStore.set(() -> URI.create("/"));
+ collectionMapper = new PublicKeyCollectionMapper(Providers.of(pathInfoStore), mapper);
+
+ ThreadContext.bind(subject);
+ }
+
+ @AfterEach
+ void cleanThreadContext() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Test
+ void shouldMapToCollection() throws IOException {
+ when(mapper.map(any(RawGpgKey.class))).then(ic -> new RawGpgKeyDto());
+
+ RawGpgKey one = createPublicKey("one");
+ RawGpgKey two = createPublicKey("two");
+
+ List keys = Lists.newArrayList(one, two);
+ HalRepresentation collection = collectionMapper.map("trillian", keys);
+
+ List embedded = collection.getEmbedded().getItemsBy("keys");
+ assertThat(embedded).hasSize(2);
+
+ assertThat(collection.getLinks().getLinkBy("self").get().getHref()).isEqualTo("/v2/users/trillian/public_keys");
+ }
+
+ @Test
+ void shouldAddCreateLinkIfTheUserIsPermitted() {
+ when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
+
+ HalRepresentation collection = collectionMapper.map("trillian", Lists.newArrayList());
+ assertThat(collection.getLinks().getLinkBy("create").get().getHref()).isEqualTo("/v2/users/trillian/public_keys");
+ }
+
+ @Test
+ void shouldNotAddCreateLinkWithoutPermission() {
+ HalRepresentation collection = collectionMapper.map("trillian", Lists.newArrayList());
+ assertThat(collection.getLinks().getLinkBy("create")).isNotPresent();
+ }
+
+ private RawGpgKey createPublicKey(String displayName) throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+ return new RawGpgKey(displayName, displayName, "trillian", raw, Collections.emptySet(), Instant.now());
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java
new file mode 100644
index 0000000000..d67125ffe4
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyMapperTest.java
@@ -0,0 +1,105 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import com.google.inject.util.Providers;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.api.v2.resources.ScmPathInfoStore;
+
+import java.io.IOException;
+import java.net.URI;
+import java.time.Instant;
+import java.util.Collections;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PublicKeyMapperTest {
+
+ @Mock
+ private Subject subject;
+
+ private final PublicKeyMapper mapper = new PublicKeyMapperImpl();
+ ScmPathInfoStore pathInfoStore = new ScmPathInfoStore();
+
+ @BeforeEach
+ void setup() {
+ ThreadContext.bind(subject);
+
+ pathInfoStore.set(() -> URI.create("/"));
+ mapper.setScmPathInfoStore(Providers.of(pathInfoStore));
+ }
+
+ @AfterEach
+ void tearDownSubject() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Test
+ void shouldMapKeyToDto() throws IOException {
+ when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
+
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+ RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Collections.emptySet(), Instant.now());
+
+ RawGpgKeyDto dto = mapper.map(key);
+
+ assertThat(dto.getDisplayName()).isEqualTo(key.getDisplayName());
+ assertThat(dto.getCreated()).isEqualTo(key.getCreated());
+ assertThat(dto.getLinks().getLinkBy("self").get().getHref()).isEqualTo("/v2/users/trillian/public_keys/1");
+ assertThat(dto.getLinks().getLinkBy("delete").get().getHref()).isEqualTo("/v2/users/trillian/public_keys/1");
+ assertThat(dto.getLinks().getLinkBy("raw").get().getHref()).isEqualTo("/v2/public_keys/1");
+ }
+
+ @Test
+ void shouldNotAppendDeleteLinkIfPermissionMissing() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+ RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Collections.emptySet(), Instant.now());
+
+ RawGpgKeyDto dto = mapper.map(key);
+
+ assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
+ }
+
+ @Test
+ void shouldNotAppendDeleteLinkIfReadonly() throws IOException {
+ when(subject.isPermitted("user:changePublicKeys:trillian")).thenReturn(true);
+
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+ RawGpgKey key = new RawGpgKey("1", "key_42", "trillian", raw, Collections.emptySet(), Instant.now(), true);
+
+ RawGpgKeyDto dto = mapper.map(key);
+
+ assertThat(dto.getLinks().getLinkBy("delete")).isNotPresent();
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java
new file mode 100644
index 0000000000..0fb51de383
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyResourceTest.java
@@ -0,0 +1,61 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+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 javax.ws.rs.core.Response;
+import java.io.IOException;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+class PublicKeyResourceTest {
+
+ @Mock
+ private PublicKeyStore store;
+
+ @InjectMocks
+ private PublicKeyResource resource;
+
+ @Test
+ void shouldFindByIdGpg() throws IOException {
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+ RawGpgKey key = new RawGpgKey("42", raw);
+ when(store.findById("42")).thenReturn(Optional.of(key));
+
+ Response response = resource.findByIdGpg("42");
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getEntity()).isSameAs(raw);
+ }
+
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java
new file mode 100644
index 0000000000..910feaf4e1
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/PublicKeyStoreTest.java
@@ -0,0 +1,194 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import org.apache.shiro.authz.AuthorizationException;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.event.ScmEventBus;
+import sonia.scm.repository.Person;
+import sonia.scm.security.NotPublicKeyException;
+import sonia.scm.security.PublicKeyCreatedEvent;
+import sonia.scm.security.PublicKeyDeletedEvent;
+import sonia.scm.store.DataStoreFactory;
+import sonia.scm.store.InMemoryDataStoreFactory;
+
+import java.io.IOException;
+import java.time.Instant;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.junit.jupiter.api.Assertions.assertThrows;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.doThrow;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+
+@ExtendWith(MockitoExtension.class)
+class PublicKeyStoreTest {
+
+ @Mock
+ private Subject subject;
+
+ @Mock
+ private ScmEventBus eventBus;
+
+ private PublicKeyStore keyStore;
+ private final DataStoreFactory dataStoreFactory = new InMemoryDataStoreFactory();
+
+ @BeforeEach
+ void setUpKeyStore() {
+ keyStore = new PublicKeyStore(dataStoreFactory, eventBus);
+ }
+
+ @BeforeEach
+ void bindSubject() {
+ ThreadContext.bind(subject);
+ }
+
+ @AfterEach
+ void tearDownSubject() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Test
+ void shouldThrowAuthorizationExceptionOnAdd() throws IOException {
+ doThrow(AuthorizationException.class).when(subject).checkPermission("user:changePublicKeys:zaphod");
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc");
+
+ assertThrows(AuthorizationException.class, () -> keyStore.add("zaphods key", "zaphod", rawKey));
+ }
+
+ @Test
+ void shouldOnlyStorePublicKeys() throws IOException {
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc").replace("PUBLIC", "PRIVATE");
+
+ assertThrows(NotPublicKeyException.class, () -> keyStore.add("SCM Package Key", "trillian", rawKey));
+ }
+
+ @Test
+ void shouldReturnStoredKey() throws IOException {
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc");
+ Instant now = Instant.now();
+
+ RawGpgKey key = keyStore.add("SCM Package Key", "trillian", rawKey);
+ assertThat(key.getId()).isEqualTo("0x975922F193B07D6E");
+ assertThat(key.getDisplayName()).isEqualTo("SCM Package Key");
+ assertThat(key.getOwner()).isEqualTo("trillian");
+ assertThat(key.getCreated()).isAfterOrEqualTo(now);
+ assertThat(key.getRaw()).isEqualTo(rawKey);
+ assertThat(key.isReadonly()).isFalse();
+ assertThat(key.getContacts()).contains(Person.toPerson("SCM Packages (signing key for packages.scm-manager.org) "));
+
+ verify(eventBus).post(any(PublicKeyCreatedEvent.class));
+ }
+
+ @Test
+ void shouldReturnReadonlyStoredKey() throws IOException {
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc");
+ Instant now = Instant.now();
+
+ RawGpgKey key = keyStore.add("SCM Package Key", "trillian", rawKey, true);
+ assertThat(key.getId()).isEqualTo("0x975922F193B07D6E");
+ assertThat(key.getDisplayName()).isEqualTo("SCM Package Key");
+ assertThat(key.getOwner()).isEqualTo("trillian");
+ assertThat(key.getCreated()).isAfterOrEqualTo(now);
+ assertThat(key.getRaw()).isEqualTo(rawKey);
+ assertThat(key.isReadonly()).isTrue();
+ assertThat(key.getContacts()).contains(Person.toPerson("SCM Packages (signing key for packages.scm-manager.org) "));
+
+ verify(eventBus).post(any(PublicKeyCreatedEvent.class));
+ }
+
+ @Test
+ void shouldFindStoredKeyById() throws IOException {
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc");
+ keyStore.add("SCM Package Key", "trillian", rawKey);
+ Optional key = keyStore.findById("0x975922F193B07D6E");
+ assertThat(key).isPresent();
+ }
+
+ @Test
+ void shouldDeleteKey() throws IOException {
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc");
+ keyStore.add("SCM Package Key", "trillian", rawKey);
+ Optional key = keyStore.findById("0x975922F193B07D6E");
+
+ assertThat(key).isPresent();
+
+ keyStore.delete("0x975922F193B07D6E");
+ key = keyStore.findById("0x975922F193B07D6E");
+
+ assertThat(key).isNotPresent();
+
+ verify(eventBus).post(any(PublicKeyDeletedEvent.class));
+ }
+
+ @Test()
+ void shouldThrowOnDeletingReadonlyKey() throws IOException {
+ String rawKey = GPGTestHelper.readResourceAsString("single.asc");
+ keyStore.add("SCM Package Key", "trillian", rawKey, true);
+ Optional key = keyStore.findById("0x975922F193B07D6E");
+
+ assertThat(key).isPresent();
+
+ assertThrows(DeletingReadonlyKeyNotAllowedException.class, () -> keyStore.delete("0x975922F193B07D6E"));
+ key = keyStore.findById("0x975922F193B07D6E");
+
+ assertThat(key).isPresent();
+
+ verify(eventBus, never()).post(any(PublicKeyDeletedEvent.class));
+ }
+
+ @Test
+ void shouldReturnEmptyListIfNoKeysAvailable() {
+ List keys = keyStore.findByUsername("zaphod");
+
+ assertThat(keys)
+ .isInstanceOf(List.class)
+ .isEmpty();
+ }
+
+ @Test
+ void shouldFindAllKeysForUser() throws IOException {
+ String singleKey = GPGTestHelper.readResourceAsString("single.asc");
+ keyStore.add("SCM Single Key", "trillian", singleKey);
+
+ String multiKey = GPGTestHelper.readResourceAsString("subkeys.asc");
+ keyStore.add("SCM Multi Key", "trillian", multiKey);
+
+ List keys = keyStore.findByUsername("trillian");
+
+ assertThat(keys.size()).isEqualTo(2);
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java b/scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java
new file mode 100644
index 0000000000..14621357c3
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/security/gpg/UserPublicKeyResourceTest.java
@@ -0,0 +1,143 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.security.gpg;
+
+import de.otto.edison.hal.HalRepresentation;
+import org.apache.shiro.subject.Subject;
+import org.apache.shiro.util.ThreadContext;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+
+import javax.ws.rs.core.Response;
+import javax.ws.rs.core.UriBuilder;
+import javax.ws.rs.core.UriInfo;
+import java.io.IOException;
+import java.net.URI;
+import java.net.URISyntaxException;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+@ExtendWith(MockitoExtension.class)
+
+class UserPublicKeyResourceTest {
+
+ @Mock
+ private PublicKeyStore store;
+
+ @Mock
+ private PublicKeyCollectionMapper collectionMapper;
+
+ @Mock
+ private PublicKeyMapper mapper;
+
+ @InjectMocks
+ private UserPublicKeyResource resource;
+
+ @Mock
+ private Subject subject;
+
+ @BeforeEach
+ void setUpSubject() {
+ ThreadContext.bind(subject);
+ }
+
+ @AfterEach
+ void clearSubject() {
+ ThreadContext.unbindSubject();
+ }
+
+ @Test
+ void shouldFindAll() {
+ List keys = new ArrayList<>();
+ when(store.findByUsername("trillian")).thenReturn(keys);
+
+ HalRepresentation collection = new HalRepresentation();
+ when(collectionMapper.map("trillian", keys)).thenReturn(collection);
+
+ HalRepresentation result = resource.findAll("trillian");
+ assertThat(result).isSameAs(collection);
+ }
+
+ @Test
+ void shouldFindByIdJson() {
+ RawGpgKey key = new RawGpgKey("42");
+ when(store.findById("42")).thenReturn(Optional.of(key));
+ RawGpgKeyDto dto = new RawGpgKeyDto();
+ when(mapper.map(key)).thenReturn(dto);
+
+ Response response = resource.findByIdJson("42");
+ assertThat(response.getStatus()).isEqualTo(200);
+ assertThat(response.getEntity()).isSameAs(dto);
+ }
+
+ @Test
+ void shouldReturn404IfIdDoesNotExists() {
+ when(store.findById("42")).thenReturn(Optional.empty());
+
+ Response response = resource.findByIdJson("42");
+ assertThat(response.getStatus()).isEqualTo(404);
+ }
+
+ @Test
+ void shouldAddToStore() throws URISyntaxException, IOException {
+ String raw = GPGTestHelper.readResourceAsString("single.asc");
+
+ UriInfo uriInfo = mock(UriInfo.class);
+ UriBuilder builder = mock(UriBuilder.class);
+ when(uriInfo.getAbsolutePathBuilder()).thenReturn(builder);
+ when(builder.path("42")).thenReturn(builder);
+ when(builder.build()).thenReturn(new URI("/v2/public_keys/42"));
+
+ RawGpgKey key = new RawGpgKey("42");
+ RawGpgKeyDto dto = new RawGpgKeyDto();
+ dto.setDisplayName("key_42");
+ dto.setRaw(raw);
+ when(store.add(dto.getDisplayName(), "trillian", dto.getRaw())).thenReturn(key);
+
+ Response response = resource.create(uriInfo, "trillian", dto);
+
+ assertThat(response.getStatus()).isEqualTo(201);
+ assertThat(response.getLocation().toASCIIString()).isEqualTo("/v2/public_keys/42");
+ }
+
+ @Test
+ void shouldDeleteFromStore() {
+ Response response = resource.deleteById("42");
+ assertThat(response.getStatus()).isEqualTo(204);
+ verify(store).delete("42");
+ }
+
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/update/repository/AnonymousModeUpdateStepTest.java b/scm-webapp/src/test/java/sonia/scm/update/repository/AnonymousModeUpdateStepTest.java
new file mode 100644
index 0000000000..08621a7136
--- /dev/null
+++ b/scm-webapp/src/test/java/sonia/scm/update/repository/AnonymousModeUpdateStepTest.java
@@ -0,0 +1,104 @@
+/*
+ * MIT License
+ *
+ * Copyright (c) 2020-present Cloudogu GmbH and Contributors
+ *
+ * Permission is hereby granted, free of charge, to any person obtaining a copy
+ * of this software and associated documentation files (the "Software"), to deal
+ * in the Software without restriction, including without limitation the rights
+ * to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+ * copies of the Software, and to permit persons to whom the Software is
+ * furnished to do so, subject to the following conditions:
+ *
+ * The above copyright notice and this permission notice shall be included in all
+ * copies or substantial portions of the Software.
+ *
+ * THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+ * IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+ * FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+ * AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+ * LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+ * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+ * SOFTWARE.
+ */
+
+package sonia.scm.update.repository;
+
+import com.google.common.io.Resources;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.junit.jupiter.api.io.TempDir;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import sonia.scm.SCMContextProvider;
+import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
+import sonia.scm.store.ConfigurationStore;
+import sonia.scm.store.InMemoryConfigurationStoreFactory;
+
+import javax.xml.bind.JAXBException;
+import java.io.IOException;
+import java.net.URL;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.nio.file.Paths;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.Mockito.when;
+import static sonia.scm.store.InMemoryConfigurationStoreFactory.create;
+
+@ExtendWith(MockitoExtension.class)
+class AnonymousModeUpdateStepTest {
+
+ @Mock
+ private SCMContextProvider contextProvider;
+
+ private AnonymousModeUpdateStep updateStep;
+ private ConfigurationStore configurationStore;
+
+ private Path configDir;
+
+ @BeforeEach
+ void initUpdateStep(@TempDir Path tempDir) throws IOException {
+ when(contextProvider.resolve(any(Path.class))).thenReturn(tempDir.toAbsolutePath());
+ configDir = tempDir;
+ Files.createDirectories(configDir);
+ InMemoryConfigurationStoreFactory inMemoryConfigurationStoreFactory = create();
+ configurationStore = inMemoryConfigurationStoreFactory.get("config", null);
+ updateStep = new AnonymousModeUpdateStep(contextProvider, inMemoryConfigurationStoreFactory);
+ }
+
+ @Test
+ void shouldNotUpdateIfConfigFileNotAvailable() throws JAXBException {
+ updateStep.doUpdate();
+
+ assertThat(configurationStore.getOptional()).isNotPresent();
+ }
+
+ @Test
+ void shouldUpdateDisabledAnonymousMode() throws JAXBException, IOException {
+ copyTestDatabaseFile(configDir, "config.xml", "config.xml");
+ configurationStore.set(new ScmConfiguration());
+
+ updateStep.doUpdate();
+
+ assertThat((configurationStore.get()).getAnonymousMode()).isEqualTo(AnonymousMode.OFF);
+ }
+
+ @Test
+ void shouldUpdateEnabledAnonymousMode() throws JAXBException, IOException {
+ copyTestDatabaseFile(configDir, "config-withAnon.xml", "config.xml");
+ configurationStore.set(new ScmConfiguration());
+
+ updateStep.doUpdate();
+
+ assertThat((configurationStore.get()).getAnonymousMode()).isEqualTo(AnonymousMode.PROTOCOL_ONLY);
+ }
+
+ private void copyTestDatabaseFile(Path configDir, String sourceFileName, String targetFileName) throws IOException {
+ URL url = Resources.getResource("sonia/scm/update/security/" + sourceFileName);
+ Files.copy(url.openStream(), configDir.resolve(targetFileName));
+ }
+}
diff --git a/scm-webapp/src/test/java/sonia/scm/user/AnonymousUserDeletionEventHandlerTest.java b/scm-webapp/src/test/java/sonia/scm/user/AnonymousUserDeletionEventHandlerTest.java
index fdbaa55401..fba3de44e1 100644
--- a/scm-webapp/src/test/java/sonia/scm/user/AnonymousUserDeletionEventHandlerTest.java
+++ b/scm-webapp/src/test/java/sonia/scm/user/AnonymousUserDeletionEventHandlerTest.java
@@ -29,6 +29,7 @@ import org.junit.jupiter.api.Test;
import sonia.scm.HandlerEventType;
import sonia.scm.SCMContext;
import sonia.scm.config.ScmConfiguration;
+import sonia.scm.security.AnonymousMode;
import static org.junit.jupiter.api.Assertions.assertThrows;
@@ -45,7 +46,7 @@ class AnonymousUserDeletionEventHandlerTest {
@Test
void shouldThrowAnonymousUserDeletionExceptionIfAnonymousAccessIsEnabled() {
- scmConfiguration.setAnonymousAccessEnabled(true);
+ scmConfiguration.setAnonymousMode(AnonymousMode.FULL);
hook = new AnonymousUserDeletionEventHandler(scmConfiguration);
UserEvent deletionEvent = new UserEvent(HandlerEventType.BEFORE_DELETE, SCMContext.ANONYMOUS);
@@ -55,7 +56,7 @@ class AnonymousUserDeletionEventHandlerTest {
@Test
void shouldNotThrowAnonymousUserDeletionException() {
- scmConfiguration.setAnonymousAccessEnabled(false);
+ scmConfiguration.setAnonymousMode(AnonymousMode.OFF);
hook = new AnonymousUserDeletionEventHandler(scmConfiguration);
UserEvent deletionEvent = new UserEvent(HandlerEventType.BEFORE_DELETE, SCMContext.ANONYMOUS);
diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc b/scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc
new file mode 100644
index 0000000000..560e37e95c
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/private-key.asc
@@ -0,0 +1,111 @@
+-----BEGIN PGP PRIVATE KEY BLOCK-----
+
+lQOYBF8pulABCAC3ENgjbXd6MmDPj4HsOQmSH71lDSQUHRwkx8qs8CdxJE9A1GXd
+J40cytl48MF0ngK39TVQQw8gSx1RmLr+knecTT+AUjk5yjIQqzdV6xXCVjFtzErq
+oZLxFDjNKJjkXizFzduoIloEG/bUFrJqiyTxnQw+pQOizVbSHQ3+vVh/XLJbbXG8
+wAj5mw/MKr3QhCRIUZVRSqtvAWPrKymm9YFcEYwNfJl4+eiL8wP6bKfmqqek3AWh
+k9WE3u17j73pdZ4PrFdxlSe5J2hN9umSonqmIQmMLD5p4qEkztkx8z3VzrYSb/eT
+KvRo6G6Z9Y5idylrgI/zsF189dm/AmeEbh5dABEBAAEAB/wL4f4Fnq9osShzkJ8g
+VDt4zrKegpHa9GDFSmqvew80WuUCEkdiaZTRT6F6JjaIeVE326TQRuoOcJHAoCdT
+KvK0pJcAn1WzmJpTVqnK2+2XpbyjoeUjAcXl/CgLuRzjhfFmDYy6hzBMn/wPnEGM
+hOeq/0SyNEfeI3IFRXmJFYVPDvsmn7p0t2YTurQJeS1lWACx8aCjpTD+oZOUaW/p
+69hAu70AieYsRqXhFW2t3XvBMam4KDGJgCJJIvLED7X3MpvJ8FwCMu2RE1yVB6yi
+c0ez7NGKAjo4zNu249tLCptlVov1zsa08bg3+WCCTa27p+EPGoV2qx2Si1uMwZAb
+bXyBBADMk4tHpQf2PDKKqzInwFtUVcnpyRt0e8sbApMg42v/MF0kRLxsfErJarSe
+hz1jrtbg1GmYnlQwyk4NhgHanVRrXTACDOZL+jzAiyLU1n/GtzBO5pjWyrt7XKZ0
+2k7qlbNiIPTmNalS/zGrhWz01bEKdcZnJ1erPcpQjB/f437y0QQA5RUZlYuC8qgt
+retWxg3oqNWf8ndYN82gTpDpnezVkFaNbPFgkbcwQXKlnfCluJAVjp5DYnCNhwtf
+LFNIrkkWENgHOqPvQ35ZZ5ZH4onU0o1MmA3wjCYCKrtOLFJ4+GJKq6mVRlbcbii+
+zAGBfxyA/ind/eEiKPCFURr8tzsEHc0EAL0d6Uy5ShFGpbbxFA4uUD7z0PTCAcKH
+kLqTGm1tOzbcK5lWT3PTIajDZQnWM1lmLNkO5loJmT9Dn7plY+hw7vpRBpRvjZST
+tJVo8zB+C6fcxHANGmFRjdSTFXvak7fn7lDF3NqTTodUFG8fZoB9qLVvfyDOdzDS
+BMS7oPzD7qNYRWi0MFRFU1RfS0VZIChGb3IgdW5pdCB0ZXN0aW5nKSA8aGVsbG9A
+Y2xvdWRvZ3UuY29tPokBTgQTAQoAOBYhBCuidyHxE8AFzBbwa65j77xJ8UDPBQJf
+KbpQAhsDBQsJCAcCBhUKCQgLAgQWAgMBAh4BAheAAAoJEK5j77xJ8UDPjqEH/3Ki
+9b5GhH8CFrD3dFC4+CWPIoJrqNtc8y1yVzqqiBxNjJ+tasq3KJ2LdSlFkafUTNjx
+htSYVLHDrGWczQHiPALe82utrRjHF1/dy/u32XifdnA9/I40wIahxpj6LY6kvHAe
+1EczhvuAZ3oLShXn/VXAIjbDIHRT6rUAcIQ/A6O0NwXXBCvZiCP3wbMNNovmVoY5
+CnSNRhsHnDIB76PEBojd+N6wf98OoQNecx3LIjYkeB/uEexz6eT5QQGTVLljSUcy
+rsd66M4XRUM42SoeRKzR47zKhc06zqmiabx/Vk/S+u8UoJgMX/4YNd0EBE6+id3w
+3nH8hi1YjQKNuaxF1eqdA5gEXym6UAEIAM3mh7ih5AeYhcAM98wdFZmmiTm8o5sc
+vdu9y7b2SW5d1DFtToq+PE4zbcMyPd9fHqUX1Wnni97YGetB3AuKuDeF8bZ6PAeb
+kV7ySqDIDjEjQsbWfoT0KC02q2kpcYMRe5ONyta+UejlZle5FGbBz6mVVQ/mWCib
+h5ytvfetXcMGzf2/oJdOgey4P32NiNLmpiBH4qQOd2K1wHY7ldSHQNatdQCyksco
+HKTJ82DFjP1Ytdi/LLmnbw5ZpJjk74XJUAhkGYKjQYPczKcae2sqIXhP8ff56PS8
+m604e7qfLMaASk5vWdhL+yRZZO2wtRKH03B2kJ1BPjzU22ZvGauu21EAEQEAAQAH
+/RG9zm4DTRG2e7fbpjJpQyY1KlfWQEaqSFW52ebO++7NmO4VXBIqaCnY1pleJ+Sq
+XoqdLh9s+yldd4ZE63/3GP53xScTCz8gkXsb54BJHKfxQNy/OLGeFCQpNMXf8072
+364MJrEwPwCRW6stYGumQY18N5MiJvCAzkOa2OaRgqW+Na3iifvWfi13LI+Fouk3
+LmoKlY04aeZDoonKtqeELEXlHUTWzinxt2/004EPTSBgOjKVID9XFa26+Z4+k/OL
+eEgQojyiM+y7qShExKDXArt0TqJjF0q0WalL9nIqJArMR7R94fz3DgU759f+ytMG
+kxvj226+5Mm2AWAi9WDym90EANTaz01JWRjwLSk9cXRrW4nuzlWybYAbBeQKsE4A
+SRkeftq02V2DibnKnIKfpPZUJ7AwHE4Yeh6HeIiXGksmd0E9wIs6a7fEsTFk71Ji
+vnDG9S4kurUKmxpZlXAkjMwqLiU3vg+LWp9DpDu5ZwvcXVnnZGPHlmQwlFdwJ26k
+PKXXBAD3otxibc6DU8uBTB09HvW06aqHjtkVcQ1wXss58k6TVr38cjKPlJcdjTvt
+J9EzWWNRDraY+cofkyKZ1/wjQY/925j6tVxegE+m3fv485VfEiPzzV2ugacWWtyt
+GtPxgBA066TkcUDUaEJTr7+JfHXncueAW4X4nuqPBF0WbdsTFwQA7FpHZ1chMjC3
+jv/xxp9EuR4vA0+fpqxvNcyLerGuguaMZ5MVKMlp/kHtWKGXk+SfKO6H6wnfYpyt
+3HjmTpBUXkK561U/HT/4gBzgA4BNRlVqwMD1J7fh01sX8xQjWT7eaDQnvDASiex4
+D4CSzUSYm2XV+3mj1nqkz9cW5NWnFydArIkBNgQYAQoAIBYhBCuidyHxE8AFzBbw
+a65j77xJ8UDPBQJfKbpQAhsMAAoJEK5j77xJ8UDPnsUIAJDOsfJEe+gL33bMuKCx
+dXPM/JGeKC7U0V1L1qBIKj0LqXSVVbg9ocYuKKsKRMgG5w8wWL14N1INo8L71Tfn
+FbgX3+SgMjiMhgSIsQuUXjbDoW9FYQssM5W0OWokoIMzM1cQLH2scY4TUmiIg8Kx
+NvfrgL0BXy7CQViAFV3lvQieyAysT71KVdhnUkYPFL0azPRJ31R7pDevus42lLkB
+uHHQgscTYy2x6DY9dMvQtj1zqY9qv/5aFusa8xRKANR/5g6XIaHccJynn7BjCgh6
+qhWbWfYit/SzJzZfGXqt0TGJvyLSto6bOxjvq8YdtcSdrWjz00j13PQLm4oPiLc1
+jveVA5gEXym6vgEIAN0hItn7dwXVeTxXilD3Qhi6pv7CchY7w8ZC4L2lYh0DuscU
+DliJVWzszc4KhbimuZHcPgFJeJMbp2JNU/CT/T4nE9+u7Vj1+n0TpMK6ZlS/0kMo
+B/dX94PVm2tWMr6inGTY8FvBqzYJ8MAF802S4TXDf+vdpCeOwhP+sFMVW5jz+HaR
+3uohkspPBx/Yi29LQzIJXWfb+wFditAp2CBcUO34VegwkzAc072OeZVxQS6vj+99
+6ZJanihzcX2hdx+BtmVSGkkaHvlNSCZrjak6/I9/hJGJPoU5kJAPc91+BekuBUeu
+hZql1oEftVzIx4PTBJahbZuxlJtz98yaI0J/yUMAEQEAAQAH/RlK2Pml0Y9RQ3Sr
+bp6kKWM6ti8dfn8cht/+dkY6zGYVLx/mI13tF2BGFaQjf/gG2eLdFhp/lNL+rr6H
+qboysxyQy60iDPPH7savoIDFYT8AUcRsp7yayyzBGe3FBjjX0JuYVKWqGTMtH+RW
+yeVtj2Te35rS1xvPMFOpJfHa14c+6hZzACte/o0bSva2bHTGO5KqqAje+l+5b5SG
+PpDDbqdTSNP7LxvQb32Xmwnp0Mu4p+G7+j9a2KEX/Hs4qV1CVIrWxHZefKyjGBTr
+dAi85BnjWDZk7aq2rmChYUwn/9AFI1oKrSZQVxUkaQBRLa9yx7KitIxqB8uT3M5Q
+iHgpmgEEAOw29M7DdpFJrKypNx+pbis2rRAJTWRbNzAoG5yiygGZOQQxAcELFQt9
+jYq6VwK44h+bmIkcCZPNDJ+zrXALD8vMsjgXKq148K1uQwA51+fInjKmeWbGYl+m
+Q37EWiaPQ9jhzKmol5PfsufAy5LB1s7cNtg49O79MGqUjEQ/+LVjBADvprbKz4qz
+fzSFBBtwTuynqS8hQv8KiQ8noqpngvexDAG0Xsxb4L4s3AntcBxWUFKks+9ZhJ9z
+9KcbclmBjM4zXgtHp5bRkMMry2+OHJJ0cWI0WhVxB6PKNXwdEkP52ifN5P5NBYod
+SVTS6YHIRWJro6hbfqcxT7oc2rTYbdRSoQQAgFVkRr0Mod84VGEmo7hp2AS9EjTo
+dILoabv1l+pvhuUHbWYD5uFWKzYPDE4xR836gKUzTJJuYRQI4TAP0q6zuhjvRCrK
+Ufdwrx2kyp7D/R7CgGfrMMdkkqUih6lHh5QxwhtCjvkBX51SlxmrhFjHODinqXF9
+Dzp6AOKcncK1ZVY9rLQwQ2xvdWRvZ3UgKEZvciB1bml0IHRlc3RpbmcpIDxoZWxs
+b0BjbG91ZG9ndS5jb20+iQFOBBMBCgA4FiEEvpYdIx71pBe65aiwVlYddtCXoosF
+Al8pur4CGwMFCwkIBwIGFQoJCAsCBBYCAwECHgECF4AACgkQVlYddtCXoouYuwf+
+LvtxIrvJaDTcivkuMejEE5dqpbo/AZrTWDIfhwoeGCE6DbbVUQTgkRC02F7otD0K
+E+vDyhluAV2GMbswcEA1p/gG7fTSbeMe59jVwI3D4EC8vRtwcMoeQngF9xWXZqrC
+mq0sdKN8V7GxVrm6LbJn6y1alzy+DLyfYQCWkTlpJiXRUK/lhAuE9hKnPkgnIt91
+mwdWTIinDFJLU+GJorRIH2BNhod5YZ8AcEPvmKPkvWOPQg9bUlbzVDK7QUYEc295
+4GkVkaTBilqDN2AHQUjiRbqXMaCFPLZ3L6ldDdEM4VX8jvzvbL6jRpfpbngKRToE
+dxj2vZNbL3alpJTrDxB0fp0DmARfKbq+AQgA3xKYsHJNBxqztVd2vHs/xkkngzqA
+xNAjdt8NAUCBTDcihGpFbblR3b5GaaF/ALcoKo0bs0MwknoNtzr44x3UGoHyUfJk
+4kxESU7G76nfyO10Vpr5agdg7WSdB7rfGaRTx33MtkrZnXtw10RDhjwzRSzyAkmY
+Zrel3r/7Sgi2Q5Y3ii0Dc0zekavKCTMS2uNiGXMD2XYB9/ogXkiie5P6uf3Y7qSW
+BarDFdrxAYjNWvoJQQ/Is2Ee6W4hAZM7WAV0zBD8d68chtS0W2d+khBBK6TurjGp
+8sIF2pVc6QC4Vavxx2JTqymjHnc/mpRc7Hgpbmh6kzc7wqT/lHor/G8R1QARAQAB
+AAf8CECfXnO0Ds25lT1ZmqpylwrQx+WLqvxKO5UP3Zp9zgyCHeTykZcYBLyLzU+Y
+q7Wa6kwTGMQlEV4rkLpBR+GsHZjuFoMBoW+R3SZpbKdbrIrAUY3lKTuBpfahao5K
+v5+ZK9mnD51gRJey+nu/hcFHYklB4LzJQw+LNtziVoBRAdoEoH0THG7iCsHY1IpX
+7W92agVFhSRpOKHWcvM98PVPubGDOniarZ8swl2sPUalNrTjMSYGsre/6zpUhrhc
+gG49C3CEhOkFGc8I1aawhmhdfUnNAwL7tL1wtEz4i+6/JIdnufdGFXZsEL0Z0joa
+bW1dKLFZdW/vBWYBz8WQPs5i1wQA7aiQgFUZO/J0Yldx/rOesc4UHj0HV2XrUOU/
+NFfxla+hTqTd4or0oEmZRpurCZ+L8dkX2z4YovHcJbcRM6YX/AZeUguJbJbBXK2k
+0HyYtUZwQ9xlV/SQgIqK8FvS/mVAbxxoGP6HIjbAzmKYd+IGBjSt+ycmtr7sQh5M
+++1bsTsEAPBJ2+hgdZP7q861RemtYK+BGISIAhUoaPmFAEFmtZerdv+ZWuER4Jhd
+3uBOcA8V2yzjv48xw76mFQsSPbDRQxtcNUiMnJQ9JB2w328yClNjnh5n/6qO8LHz
+hsVcoS4BQZ4U74852/Ddc/fByQmSQUsvU75tqON7PsNK2HNwNBgvA/95K4Va4zFr
+Ze29msF7BnZlxom7J4js7hWgrYhkXZKLZ4YFJ0IaGUYgARc+9G2Xx44DrqJNfjMD
+a+PJHjwV0dqtEgbO0U4Zii8tUfkzcXk9/K0VerK0X4LfcRqWnPi7b+ActA6XggAx
+QWNZIEIjCqrKqje2SziSQntRIaGcbGNm4znziQE2BBgBCgAgFiEEvpYdIx71pBe6
+5aiwVlYddtCXoosFAl8pur4CGwwACgkQVlYddtCXoou5jQf9HIZwPQwuVBzjIJ36
+7EwLX68ry0r2cqho3jD/s1lVdwLefxseYW5fztOLoAOEzVbx6zXP7PQb4m0fTROD
+BMl2P/TxUbAPVs/+CF/8LbK4piFiJUgfOiV86LjG4WM1q0XEjYMmmc6ocuUmQFJx
+bCuR7x4rVWw5DiHTfQfyNnrvZ+X15rtGzB3X7jnuft/RnrwamFmET6ixuDfn3Zlx
+6vFrGoj7ViRjryEZ03jk3eL+O66GAuEBbHxy8wUkEe9VJBcbe9OQmmqI67up4fqw
+BZzj2T8sKqE3Yq0TrruVgK7gzXDrXIaEqz9F2H3CT2JxFRPddHEJyF5RsMA5wD1L
+8kaUFg==
+=Vt5A
+-----END PGP PRIVATE KEY BLOCK-----
diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/single.asc b/scm-webapp/src/test/resources/sonia/scm/security/gpg/single.asc
new file mode 100644
index 0000000000..8c06de15d9
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/single.asc
@@ -0,0 +1,30 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+mQINBF69VQEBEADcnNUucubBBn0nEubrNV3SWh1CbiUyhLU6TEKSUbyB2gDcOrMR
+4wAeqK0ar4cloIpS5YEtjT/qVpERzabbJe0NSfCwYblpdfoA0idcvi7Ssnczfr/i
+1cF0gqmAjoDqAaSk7xHa/mxiEwAUAXGDWu4pCksT8gHDDx/7lIkHvPZs+VGyMM/O
+NVc3QET0JMuKhBYJafSJkdUl3qjX9N6ykETQIbxSv0YLjZuAzQJggLoiMWZtDkrU
+KuXh/bJja2xnzj9XtMmpkrIRAX1VYLlI7sJ17b1Tv/yIvaZ1akccRSFtx/kqcM86
+LSO77E82EfLhuVeVCvzxzgfrYHVVX6oFhAEzKUI0TRT453yGZuC5j91HXP3VuXjH
+opAODeQLbDfcWPH8joegDZBuK1dQRvVcyh5CMSAWHw9vgXKergrMHVB4EVIqMMir
+Q29KfbSuhA5D+xLxPjphbDsMIcLMy0ADd7N0ydmpt2x7ES3Sx3iTssibBYQB731Z
+DQCgxy8mdL4MSCNUkDziGyqK+cI9/jRehiOimFsnQIDqQ1hOQBw7M21lvGlbn1IA
+iKosi9rb+tUtnNT2d3byjvjZzMj1xoJeOs9i+xEDu1224xEEfJIixfSLLW2T8Qdb
+6/a5XGENQB9ZfcW0CrK+V+bHLKXkY2MG9mAL3KEgmDDqydQTwOGNFzJHpwARAQAB
+tE9TQ00gUGFja2FnZXMgKHNpZ25pbmcga2V5IGZvciBwYWNrYWdlcy5zY20tbWFu
+YWdlci5vcmcpIDxzY20tdGVhbUBjbG91ZG9ndS5jb20+iQJOBBMBCgA4FiEEI9Ji
+WyNeJaRxmHWil1ki8ZOwfW4FAl69VQECGy8FCwkIBwMFFQoJCAsFFgIDAQACHgEC
+F4AACgkQl1ki8ZOwfW49vg/9EYZSEejdfuzLWcC1M8C9lyausvB+SAI7fEcnD4do
+w6WEdnPTus5aAnr1qOncH3aJpjwqfIpuCMdS94i9jgLJTLaQ8S2WegLFVhDQvC7v
+Q6ZieOUAYVWJx2Klq2OT1MVJPEzskV3QtFBTaHmuseJrGvH0Waw26MGw8MiAPyES
+oZGdcULZBwpr8nazqcFXFuDxMFr1Y28sEzW/ntfScLnIVIVXAWaAXq/4dtB1cIIc
+KKsszkM018HdEPSf99ry/nqiwGkOBqMUiEM9+VIMuJRs+BSvT9ETM0yx21fYV2Jj
+YG20ahsd1tRFwYLLyzwukT9KUBydZ2RZP+L0gkC+WrfMxvreQxP7d8PH8aF2Ii1e
+SpLs91h97tXq+ucp6YyGTEsVnajQeGSA0mX/AhOe3swBNZ08vuhSWkKjKnOXqR4h
+IyJaJGAuo6vd+GzdAu/9MxWZQZTWERauofxLTzESwJl7WfTgEFvF+7hNCkQmUA7r
+oGc8ahEvuGCZG2MtfBPSPL51FifDlO+G0rifWqHuocZWdBX6fcmt+SYb8SHaG0cu
+JP35uWVGuva+Bw73+S21xU3yIjt8bTkZpIuHO9xhivXIOVR3jVhB1V6KrI+jlZl/
+DmQc8MI3+Ez6Hh3kVQjokL1W/u/gg8XumCmc+4hq2QIOSF/ODUMg0b1nne4msLc/
+H1Q=
+=5/Nh
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/slarti.txt b/scm-webapp/src/test/resources/sonia/scm/security/gpg/slarti.txt
new file mode 100644
index 0000000000..cef9ef5b9f
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/slarti.txt
@@ -0,0 +1,9 @@
+Slartibartfast is a Magrathean, and a designer of planets.[2] His favourite part of the job is creating coastlines, the most notable of which are the fjords found on the coast of Norway on planet Earth,[3] for which he won an award. While trapped on prehistoric Earth, Arthur Dent and Ford Prefect see Slartibartfast's signature deep inside a glacier in ancient Norway.
+
+When Earth Mk. II is being made, Slartibartfast is assigned to the continent of Africa. He is unhappy about this because he has begun "doing it with fjords again" (arguing that they give a continent a lovely baroque feel), but has been told by his superiors that they are "not equatorial enough". In relation to this, he expresses the view that he would "far rather be happy than right any day."
+
+In any event, the new Earth is not required and, much to Slartibartfast's disgust, its owners suggested that he take a quick skiing holiday on his glaciers before dismantling them.
+
+Slartibartfast's aircar is later found near the place where Zaphod Beeblebrox, Ford Prefect, Trillian and Arthur Dent are attacked by cops, who are suddenly killed in a way similar to how the cleaning staff in Slartibartfast's study have perished. There is a note pointing to one of the controls in the aircar saying "This is the probably the best button to press."
+
+In Life, the Universe and Everything Slartibartfast has joined the Campaign for Real Time (or "CamTim" as the volunteers casually refer to it, a reference to CAMRA) which tries to preserve events as they happened before time travelling was invented. He picks up Arthur and Ford from Lord's Cricket Ground with his Starship Bistromath, after which they head out to stop the robots of Krikkit from bringing together the pieces of the Wikkit Gate.
diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/slarti.txt.asc b/scm-webapp/src/test/resources/sonia/scm/security/gpg/slarti.txt.asc
new file mode 100644
index 0000000000..f2262dd80d
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/slarti.txt.asc
@@ -0,0 +1,16 @@
+-----BEGIN PGP SIGNATURE-----
+
+iQIzBAABCgAdFiEE5uxwNFYM4xFJsy3eJH6QjG/TVHMFAl8ii3QACgkQJH6QjG/T
+VHPIWQ//fz+n5HLeIDWMeMhvkNes8dwGzdfHme/Yyb1vocqGj3VK+xr3YVjum09h
+NjKJvumazdALTUXnXNW9T57LVD3kAJpAnwCHFtIQvPmg0EVn1oz7WDh+YVVA2Ko4
+fGgH0dB64N2FUEmCYU8aV8wKUOgQ8Fh5FcSggzC5UegU9yZou+B38AfI55od1Ay/
+jk5tEExEwsErjjhDZFho/D/Ybp43otj4WtVy+fPHaZYW7TzKRVBi7ngqAlyCFGwO
+W/xEy11nv1apXV+l3iGxJkU2jlCi7ORbxH2ooSyhrC33rWxAtdYxgMElF7lRbnoc
+Pg8EQXZ8zmEwgm9u6+Ng0/qsu/wajV+QKSDMRJMhmFN0zpdvyscvaFcowcu6jW25
+Smz/Gs5B2oASDh/L/sLxUdSfCHVM7gk6HYHWNZgSajtpgLeJy8/wxOSYmB2TD72A
+ktZN2v5adkaHM8rEXLPdD0BtCMGs82pxgHEK42ncW6RFFdiOkgb6KPhkmhlxl0XU
+r64mfHj3n/dNBR5LoSbDFtHD2LakN8CPcubURneA/psfUiUdfktl6KcDYsuS1fJk
++XdxAdVUIqf3MwQU3od1nklu5Sybv5+Q2MZOstGn7opGuQXndKFtnC4WOMfo0w+X
+HTZilw/HDYN0wgzLl5YpHWmZ5MQl5/aN1nn5js3vOhgEF3+qhvQ=
+=3ZJK
+-----END PGP SIGNATURE-----
diff --git a/scm-webapp/src/test/resources/sonia/scm/security/gpg/subkeys.asc b/scm-webapp/src/test/resources/sonia/scm/security/gpg/subkeys.asc
new file mode 100644
index 0000000000..eaacc24062
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/security/gpg/subkeys.asc
@@ -0,0 +1,122 @@
+-----BEGIN PGP PUBLIC KEY BLOCK-----
+
+xsFNBFrcb6YBEAC8MN9AEOLPxa+6Bkb0Wjx2zxhWUMN1au6Bv4KPorYgkJGnU10L
+KZl7a4CwoDeFMZMgSa2GLvc8gbh5iGINsT1WLimUQ611V3AQ7HRhKL87oZJODbNM
+Pr3yjx0at+z1Z9dK3VRqorU3rIFxhzXfjRU/Y+JGUvAnpr73jP3JJLHz4KE3Emvz
+iqFDy1xJEbUvzrAMeqEEfiru6KCiyAGrjiC86U6oMPCh4AtNIKmn8+tmrYN/tllS
+M8z9oYIuGUzWDGbqWtoBM2iWbMN8wwU7rc4kwokH+hKN7EBbKerK028mZNxNLuvy
+Y7BaFw52NqpDH5VsGL1F4TkYd5G4LdSg+r5wPvgfwlpXtGOZwD6gTkuPixFYOBJ3
+enK6Y1fhWxXhZGH5HhTBWsNo1wlwX8LkTDkNmQZW+YIRrpo9CKlcgFOq9h+0Z/xS
+cKhuZOec84didqP1geyZNLJMSzuXa2Xc/cwraQgGSdfGK+WjWF2i1Jhe6OvGKOyI
+vgcPxx2mejfKiePbnbQ7gw8CrUW7yWqyg4hUOuFcBra+aH9KXUmWP3+3r3dDZtOu
+sNODkyq/3/h8Lu+9sXNOAwqUX/2gElzWPVQtj/kx0DCEl3i7iNJHzsPhZv3eM9Kc
+kf9cm2tAo1CH5ONNMlNil48rzynG1NzHeu4xXUrjg5rBX+qHfyS5EPu/CwARAQAB
+zSVTZWJhc3RpYW4gU2RvcnJhIDxzLnNkb3JyYUBnbWFpbC5jb20+wsGOBBMBCgA4
+FiEEmnnEfgUVZC1WsgIfE7E9TIqTUKEFAlrcb6YCGwMFCwkIBwMFFQoJCAsFFgID
+AQACHgECF4AACgkQE7E9TIqTUKF+BxAAk6TMuUZ96eY8COUD61T/P/8zPeiJ8zqb
+Vrn+oI1SBq30GSkfuwpKg1JgZq2Rucj/9dhaZ4DBZuvpCNh68Z8ZlDir0iNAthGq
+nw1LaFjQEMH6wzZDi5BaYaNijlPb8/zPq+3CzFqUccdLJOLfoyq/EW8vuosAzs1G
+B582UwwwjOPok3nmxA9T8dfngSMQtIkZ/NwVfrSCnapqS5PYs6r7tLgzo1EGsII+
+pTAEXJF5n2meKUMiU24aiV7Nlj5FgkcON4r6RUPqS79cLySMawfiNNfPIdMkNBiU
+q9ab74nQCealn2gCFEjYWd5n4wBKB6H64Eut4nmVECzfLmQaa+u9DH3uy30s/0YQ
+GTzBFgTDkPHHWJ9hgMDLfyXqha9OU4GVjl93HMalB+pvqVRA5Bv0CEEhzzWKVV1o
+ayLW5PgrrQYpuHcN6vyoHJbAWV77Aulc5jbcT47NLi/8nEP14ui9TfyAAKhEKpzb
+BMdr82J/J2cfTpY8WXNdQU8F4DIM8xANz9bi1UGH+IXKVyVkNb7uK3z2vsuUcW9s
+sMJaNVoQHKTuu8DPZivjdwDkQdNHDxyVtSsdgAgQyoCgbKyYFUpN6pGrlRtzMRpz
+Ns4BiBZnDm+ROFfh9azHQ8uR3ZsIi14iUv/8z5nLsFgHDefe1Rn2SS8ATVuRcC4M
+1DFg4dUEXdTNMFNlYmFzdGlhbiBTZG9ycmEgPHNlYmFzdGlhbi5zZG9ycmFAY2xv
+dWRvZ3UuY29tPsLBjgQTAQoAOBYhBJp5xH4FFWQtVrICHxOxPUyKk1ChBQJa8WoF
+AhsDBQsJCAcDBRUKCQgLBRYCAwEAAh4BAheAAAoJEBOxPUyKk1ChSFQP/2E0PLJ5
+84M0RCNC1miPrhGUH1ZNwJGJYdyG2m3FUQvE8kSWlnBPaPsboTQouTeYUdJBg6th
+cMJP2t5Iq6s7KeZMjbSiXunLCwjR8biyAVMn7+GiDvgsgpfZeWocgwj3rpzLiocv
+VEG+kMnQwBs5fIyM2eovsS4Mpj+Z/Si/YJjQJPc9FLfNJTNNFsmTwBxkd/pp9yqZ
+YmZh+uTAzE0G+XYhvXzKMrhfD+xYqoqIM8FRolpZeGyh1dNbi/ybNuM8A+VDsuIe
+r9Z7tPwrndIRXHygA606zVagWIPwy0zqTwzmzIWdnHnYF5daI5+jzmVIROW1xvJe
+QXdFlozVxBXXgcxxVSpyNEk88n9NmUwkdnprvCS2r03Cc4VsK/DKMqDsI27XLP6K
+slO6VTRqE62/ePwWCOXSPPZvIS14IDqUKKngjm6KWPjJFWthRyNPXZlxMEWX1ASS
+q55tSpnOyvod09ZGjVQFLMsDhmTfqmP1ncqJE82cVzGLTgP0D/fycJRp0W4C3A2v
+/riK5x4BnjwlPcuQ4FWgvnEK2f9Z1TyzDBnTkvDyyMzDrSDOcUsb5migALLS/XnE
+/fruwfjk+ZBkPMS4D/DT1BbrR792KXKWTNstK05D+IyTQyMW5jetH1PkIRqQdjPH
+awT1Gbq/DR7N0owTNW8hsKee7b5W58XrLGFRzsFNBFrccEcBEACu6TW1lFtGAH4O
+mEGJV341lYvXFaewHHmkaWkgql7IDMTWSjj/D0HR0sbOk6R/EpfjrRowmymrFsMy
+WzC3mqSrrGHP0qZiQPEWXZxDhl+fIXjTOqb5Kh8Huja7Ni090kb9r66/pdz1hdk7
+YZJUYlY3GZlVZEAwfGpxSlgNGwmO2wz5ihn8GN/mzfiELgIxWf9eQ3AsnrV+/JGu
+TLy/twqkPpqjdGW3kC1PGnBbPIXUWPfTYrj9T2li5BlgpGELI4TNHNEc88htyprf
+A88zASRLsZyIUUVZPIQ8bpRwMgB7Y/RMiKxps71D4qwhNOOUqGnwZnrLXvaB6G+k
+ZBaJ3AcCtgzQD/4wIx5uhJ3/tpyDvq0c471P+Ph2vswEiVOcJcDqrUbGBYtjFr3S
+iAX6h434uJbgGr5Bxos0jQ18J9PohvEPb4qsOb6PhOSJf5+YpORNanZIcMwq6JIm
+rR95XdCuBSRg6h8qXPxNdJsU1roMLcCkgEll1fPABYvVWASKRZIWWJ9pevS7oqyD
+Oo82bAdh3813WqAEflUj15S4LQxnLwjNUuW+HebzMct+z2RN6l8ZH5TQ9fhOkQiu
+fwNG5bzckjKb5UidC86FaqlJ5LTZTfEeyAlE/4chtzpyEZQo4le7fXFs6NBsPS6R
+DG/cgHynWu3QgQtGFSUzrtewWu0fiQARAQABwsF2BBgBCgAgFiEEmnnEfgUVZC1W
+sgIfE7E9TIqTUKEFAlrccEcCGyAACgkQE7E9TIqTUKENJg//bgnckWiang7BlfBd
+19UiAKM7xsJv3jGVh/WVRj10MaG/53fIJ0Hl+AgyGHWLX+d+N9AuJKC5UuKEInBQ
+zjmNFdqO/N7egCTiy4VndGybZbDim1+ZliDipvpfJNWJv34mbBk7Em6Oyw4L1EOp
+2WF38XU8u6kB9ENF5hEp/antkKpFmhfKbScWCV0koLmPLXqDKYw76YAEkrWgirt7
+SvHOZGLJ7Ie1Mt6HCG/lAqaVE5LXHnAikE0F/6sF8VhC/tfU7FEVB06eJicnIM/q
+zjBhHBkFDTQu0V5/aRrCB1dNIArTiScW7/QHU+qRFWQuhV1yjBMMSoF1Nd8f5HsS
+jKhOfIKStHh+IWXu93f2p/T4v1SPDoS+X1L+Gsw+W9Uej/8Wb+HGISFu81dfwjOG
+JzwSmRiaAlXfjPq13V65CG8IWsisy8MND/ZYf2fgibtga6cAKyxEtn1s8MjO05+a
+vWPn/iygbXgFNO0bldU0EwqiNT7A1Vti7ZrEvHc23KQG7e9A8fbEGzN6QCElaWpV
+lbYpcjNlbbf/V6XYmBC7P8AAA6T+hK/mF33FV1ttWife0cnenopZBA0Roi2A++LM
+g7Kfd4sdD9SKCyZP3uNjK6CYF3ShOo8CQ7jq/UwNeZC2hVcWd83pqf8RBJK4tvuX
+t8jOxKDk88luRYzfv7UAJCrksafOwU0EWtxwHwEQAMXIy03q+A6wdKMUUwZFIwN7
+r5miTHUg7Leb4AeKJhCUqv3Z5ZNbERn7yt/n5OeGNOtAnpGDUog9XCql4LGAgI6z
+sRv+yPIvaOnAA0nlfWDig0E6BjySqExGxeniiRvopyAT5o9Jnn82O/6r60q5LVuL
+JFbzBJ1ov6Ro+JTzAnT6DpQe1K8zosIzrXCwa7sH/r9MuqWiv/sePSin9heYUH0n
+N/UKKscSPGsT7gBwmlR+5J81JTeP3c0SxeZIPpiTkJepqNnsa6p51NKML9Z47+Hk
+hp+P2WncIjSADxWE9o7hOYhgH4kq0vjExEsNv36QCMlgv5bsp8M2nT72kPyklgAn
+aMx9UsKzZKQtuF62Uozka9rG18OE2SG7N0nCFcW8wiq3r/3cPMtrgQiBJ8qrl9di
+/gj9Sa7o5jdcSNQyqXxlVnzJ/0j1Xc5/7CB3zsaRB1XLQdLmGCv4LIJwRszZ3ZyC
+UACfDmgKN45B9PUiBFd5m2Yz3GXNEUcetedLFT1fa9r/S/RKHXu3uDTPTrhJk+3r
+26RShFnw4iVc2QlfoPdFBk2MR+kRPB43nsNs8c07JjiX1qguAPLDgRtv+P9TAcJl
+/4cIwok+fOziWe+GIS4XEV6wZhGLc3ULTabd54lrfm+w7Lj+Aazb4w1YtJUX7t8i
+C8aUAEXjRz/EIdpQF0MZABEBAAHCwXYEGAEKACAWIQSaecR+BRVkLVayAh8TsT1M
+ipNQoQUCWtxwHwIbDAAKCRATsT1MipNQoVdSEACShtVZ/PPyrDmpaOmHYHlxWk1A
+Wf3DghVx+yTs+1yHU2Wz22y4RlJ/smqriPbxmgrNgRs99b372vjnQE6L9NsfP4HE
+qK4xxtaYPsxFMO9F/Sk/cgBZdDjl8Zp65pU7XVVUj+Wl63tzKF3aeh8/5qFwg7HU
+E/vTJuZtOgnr4YL+KrJyTqUIL1HLc0jVOjw/Y6emKr5Q3HcXE939ssXOIvMIB+yo
+OJTqmv9QspLIBjPxPjyZPJYFPNKxN/Hmy1/4jxQBuTiKptdt6PxnXNBqneaMUKU6
+IltlRYP7/owK0eTz2TR59dxxwA/CyrdUjYLEzyCsmJ30yhy9pgI6DbrkMy5CzPi3
+00CFUMDx4GxDNZvXVCaA+QF8ld6edKGuSIKLtwlEyhBRGhfJJDVZT3tdHnQixi6w
+Jxbah4QckR6e5157blgaJkltpsXf3XeEx0XzoibLvLe8R4hwSJsEVz5DnBvAAPCh
+lSB8Er+SLHl24pGEQ7VzNyE9dIWWlnKeFVnN2v511W6jc3tWoGv1irrpKN8vzbMY
+3zfYcA/IRimm3yXJnktpBrOGSaetvMtgKOxkicVwxJPZwZ5JTELR2El6dPbB81kW
+U/gtfBwHC6cW644pYTOZxf00VwCgP1Mc7hmCD1CLRvE2wrvcsmFHaIM7JMxbYz9W
+w1JojAOCXMyE8VwAWc7BTQRa3G/2ARAAraCFGe+NXDyAr1o7cJyAcx+PhZ8wMGCM
+uyTwf1u7DJSlmh/zHNTMBwlF7GBIxOxEG2/tjA1ft6f9H+xcx4Q0RVNFS3hagw4i
+UiJ/N8z4lFrT0HO4O3Nd/4x4HLlErT7yjE5eBXJEZP4quYIxoE6JcUyKIYOfPrIU
+/Q6qtB99XX5WQJJcO95v9em2cBwcrBbuAgq/7rfvIfx6pJY5tx9SAHeJ7EWMsUIx
+XOstyEnuiTEvz9YIAFZlApHgs7CBzRPLk6gFcWbW0o817XFy1k6F33o37E1YmxLt
+EronBjJbveBRFTEngVNRileSw/GNoS2qtyS7y7hz/LLSSADRZtF7t3CeUE1wg1a5
+LVkoM9pLQPIXBp+AkjPS5TENCd47aqa4cFZ2x9P43+oJC6zl5pQ8dyjefJRg5LZD
+1azcH3S1vvpAxlehAa3CVu0X6iTI2ymlf2idqkgn/lXMlvN5MaSBJ9hBbs6ylRJ5
+KUsvX4jsAdhKpHefgbOTaAEoCpwxrnyRB/LUJR48TLCD3GIpFYiLPrtp+zvzdXD4
+ztQ/udph0XOkQtNUP2ctaNtyExN6W+ZuvmLveRjPgWctctmvGu/jtgAEcjVxi5hx
+VQBxS2CZmHUd0PeTW8GbUwu6sr+ju2hejCZZ6fFa8uWtthkMfF0WkqfjfNSqAAuR
+REGPJcj95nsAEQEAAcLDrAQYAQoAIBYhBJp5xH4FFWQtVrICHxOxPUyKk1ChBQJa
+3G/2AhsCAkAJEBOxPUyKk1ChwXQgBBkBCgAdFiEE5uxwNFYM4xFJsy3eJH6QjG/T
+VHMFAlrcb/YACgkQJH6QjG/TVHNz9w//fXLNgN0xSv6t97Qu0oHpqEkc60HemBsp
+13fTrf6SmXvsZq5kSKBxre1+Q7FbmuRBVArdGPWzynuE9AQz4E+cH/1sd3nf2M+D
+9feNQoZaqZ2g6AWfYfa0A0lBa09OuCtLjUmaTraCKH5z86WahPQF1uXE1tuPhdnk
+oGAaZdIjUmSnYaN4e1gYHKj5YXFT0+V6dHTrKIkLZ46l/Jg5ujPfLs0tZayV7w4V
+p7O8piPKRGv+Bco7zfGU7LuWAFPCsdJO3hruvXXSrcBsGWvioUmdetAMxuNxA+5P
+FAXeK+LlGXhy/TljPqFNXCQ3MoHG11qqoqy0+Po/cft0hKAtPuqDCq0kB/BcAZ4G
+nAO8mMdSNrCjmZU+6talu65PdFmjccllEz3xWLAcfhulyZeoP1J1y7durQXR8l0g
+6tqnKKv5oys978RDtiBoPYfqGLmPs/k8ZfyiXyPdTZWujgbnsunRtLs6sjMhtjKx
+smE8zB+bSOYgFJ9Uy0G07QQ7LwMjuf5OEdpRUHklrlQJzZMAXDoAnIxiuiVCYb/y
+wtC3C3l/0y7mdy/OP+p3RdAtU0PWOVQ+Y+R9rdpDvYRWmUh+tssSORhgoE8tG7Zr
+U/O52Jk2jmEOySEDUdQE4tMoa6P9PZeu+5V/cAtniUbhHoEaGAAc1DBtm+CA91zH
+Ei38RrBiJMysBw/+KX+1r4qn4i24apXiWPWdYqupZbPlaAJvpZNvzdEEtVoEzYET
+NB97cb/awL69dndFVS/kYFiyX4MhtYHGqnMY+A4zHHUlKR5GXTI7emMMTbhAPkrr
+haDA9RnPB1wMLRMdgGOHJmf5mF3ILXbgw3m7BNBwjlV/NMSb6w04rlvpUICUWOGa
+K9DTnbI4kYGQyevZ8lSsqsYQ2qwMc6l7bN6HYECm7P1Y12W3q84gppk279Q79pZ1
+EXkA3pii/g5eRFGsK2CrMZ3kCR5Iz2Fm4Bz5Nf0BWmXlxzVDqo5GdzqM7L9orLSg
+gukrzQfNSTAPFw8RcxPQL66FWgoDokv5I8fz32/gewMSAftWml0ivHe49Ie1P0Pl
+l66QTvE/oF72FnAn+Mn3GQtil7vrwfppnA7MOf4d3u5+a1Qn70qDMp3tA1iXqk7q
+2rTU36omQPMhXvjD8fW3WG3C7k9sHOUOcsqxFP7uz+WXy5Na/13d9NEBMx2s4IeB
+cbD5fJCo0mfNb/fPJ6Ox9/vwbogpNNDjxOmagH8NKQxnMR7Ed7sdvT7nEKc3loUc
+CwtBYY8vJBaDn/azrGiiy6WOqlvifacZ6Av7lqixQr2YMCfWwN8nyqdYMvkt5fSR
+jKYdhsFR9kijHkFxfze2d0Ag/rPYDjAX4MFgBAntlfAvofbX3Jz3/AqlZd0=
+=dKpI
+-----END PGP PUBLIC KEY BLOCK-----
diff --git a/scm-webapp/src/test/resources/sonia/scm/update/security/config-withAnon.xml b/scm-webapp/src/test/resources/sonia/scm/update/security/config-withAnon.xml
new file mode 100644
index 0000000000..1ae982be55
--- /dev/null
+++ b/scm-webapp/src/test/resources/sonia/scm/update/security/config-withAnon.xml
@@ -0,0 +1,44 @@
+
+
+
+ admins,vogons
+ arthur,dent,ldap-admin
+ http://localhost:8081/scm
+ false
+ false
+ 80
+ http://plugins.scm-manager.org/scm-plugin-backend/api/{version}/plugins?os={os}&arch={arch}&snapshot=false
+ 8080
+ proxy.mydomain.com
+ localhost
+ false
+ false
+ 8181
+ false
+ Y-m-d H:i:s
+ true
+
diff --git a/yarn.lock b/yarn.lock
index 2b80bb94fd..a6f9f597d0 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -1070,6 +1070,50 @@
exec-sh "^0.3.2"
minimist "^1.2.0"
+"@cypress/listr-verbose-renderer@^0.4.1":
+ version "0.4.1"
+ resolved "https://registry.yarnpkg.com/@cypress/listr-verbose-renderer/-/listr-verbose-renderer-0.4.1.tgz#a77492f4b11dcc7c446a34b3e28721afd33c642a"
+ integrity sha1-p3SS9LEdzHxEajSz4ochr9M8ZCo=
+ dependencies:
+ chalk "^1.1.3"
+ cli-cursor "^1.0.2"
+ date-fns "^1.27.2"
+ figures "^1.7.0"
+
+"@cypress/request@^2.88.5":
+ version "2.88.5"
+ resolved "https://registry.yarnpkg.com/@cypress/request/-/request-2.88.5.tgz#8d7ecd17b53a849cfd5ab06d5abe7d84976375d7"
+ integrity sha512-TzEC1XMi1hJkywWpRfD2clreTa/Z+lOrXDCxxBTBPEcY5azdPi56A6Xw+O4tWJnaJH3iIE7G5aDXZC6JgRZLcA==
+ dependencies:
+ aws-sign2 "~0.7.0"
+ aws4 "^1.8.0"
+ caseless "~0.12.0"
+ combined-stream "~1.0.6"
+ extend "~3.0.2"
+ forever-agent "~0.6.1"
+ form-data "~2.3.2"
+ har-validator "~5.1.3"
+ http-signature "~1.2.0"
+ is-typedarray "~1.0.0"
+ isstream "~0.1.2"
+ json-stringify-safe "~5.0.1"
+ mime-types "~2.1.19"
+ oauth-sign "~0.9.0"
+ performance-now "^2.1.0"
+ qs "~6.5.2"
+ safe-buffer "^5.1.2"
+ tough-cookie "~2.5.0"
+ tunnel-agent "^0.6.0"
+ uuid "^3.3.2"
+
+"@cypress/xvfb@^1.2.4":
+ version "1.2.4"
+ resolved "https://registry.yarnpkg.com/@cypress/xvfb/-/xvfb-1.2.4.tgz#2daf42e8275b39f4aa53c14214e557bd14e7748a"
+ integrity sha512-skbBzPggOVYCbnGgV+0dmBdW/s77ZkAOXIC1knS8NagwDjBrNC1LuXtQJeiN6l+m7lzmHtaoUw/ctJKdqkG57Q==
+ dependencies:
+ debug "^3.1.0"
+ lodash.once "^4.1.1"
+
"@emotion/babel-utils@^0.6.4":
version "0.6.10"
resolved "https://registry.yarnpkg.com/@emotion/babel-utils/-/babel-utils-0.6.10.tgz#83dbf3dfa933fae9fc566e54fbb45f14674c6ccc"
@@ -2460,6 +2504,13 @@
prop-types "^15.6.1"
react-lifecycles-compat "^3.0.4"
+"@samverschueren/stream-to-observable@^0.3.0":
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/@samverschueren/stream-to-observable/-/stream-to-observable-0.3.0.tgz#ecdf48d532c58ea477acfcab80348424f8d0662f"
+ integrity sha512-MI4Xx6LHs4Webyvi6EbspgyAb4D2Q2VtnCQ1blOJcoLS6mVa8lNN2rkIy1CVxfTUpoyIbCTkXES1rLXztFD1lg==
+ dependencies:
+ any-observable "^0.3.0"
+
"@sinonjs/commons@^1.7.0":
version "1.8.0"
resolved "https://registry.yarnpkg.com/@sinonjs/commons/-/commons-1.8.0.tgz#c8d68821a854c555bba172f3b06959a0039b236d"
@@ -3298,6 +3349,16 @@
"@types/prop-types" "*"
csstype "^2.2.0"
+"@types/sinonjs__fake-timers@^6.0.1":
+ version "6.0.1"
+ resolved "https://registry.yarnpkg.com/@types/sinonjs__fake-timers/-/sinonjs__fake-timers-6.0.1.tgz#681df970358c82836b42f989188d133e218c458e"
+ integrity sha512-yYezQwGWty8ziyYLdZjwxyMb0CZR49h8JALHGrxjQHWlqGgc8kLdHEgWrgL0uZ29DMvEVBDnHU2Wg36zKSIUtA==
+
+"@types/sizzle@^2.3.2":
+ version "2.3.2"
+ resolved "https://registry.yarnpkg.com/@types/sizzle/-/sizzle-2.3.2.tgz#a811b8c18e2babab7d542b3365887ae2e4d9de47"
+ integrity sha512-7EJYyKTL7tFR8+gDbB6Wwz/arpGa0Mywk1TJbNzKzHtzbwVmY4HR9WqS5VV7dsBUKQmPNr192jHr/VpBluj/hg==
+
"@types/source-list-map@*":
version "0.1.2"
resolved "https://registry.yarnpkg.com/@types/source-list-map/-/source-list-map-0.1.2.tgz#0078836063ffaf17412349bba364087e0ac02ec9"
@@ -3850,6 +3911,11 @@ ansi-to-html@^0.6.11:
dependencies:
entities "^1.1.2"
+any-observable@^0.3.0:
+ version "0.3.0"
+ resolved "https://registry.yarnpkg.com/any-observable/-/any-observable-0.3.0.tgz#af933475e5806a67d0d7df090dd5e8bef65d119b"
+ integrity sha512-/FQM1EDkTsf63Ub2C6O7GuYFDsSXUwsaZDurV0np41ocwq0jthUAYCmhBX9f+KwlaCgIuWyr/4WlUQUBfKfZog==
+
any-promise@^1.0.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/any-promise/-/any-promise-1.3.0.tgz#abc6afeedcea52e809cdc0376aed3ce39635d17f"
@@ -3886,6 +3952,11 @@ aproba@^2.0.0:
resolved "https://registry.yarnpkg.com/aproba/-/aproba-2.0.0.tgz#52520b8ae5b569215b354efc0caa3fe1e45a8adc"
integrity sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==
+arch@^2.1.2:
+ version "2.1.2"
+ resolved "https://registry.yarnpkg.com/arch/-/arch-2.1.2.tgz#0c52bbe7344bb4fa260c443d2cbad9c00ff2f0bf"
+ integrity sha512-NTBIIbAfkJeIletyABbVtdPgeKfDafR+1mZV/AyyfC1UkVkp9iUjV+wwmqtUgphHYajbI86jejBJp5e+jkGTiQ==
+
are-we-there-yet@~1.1.2:
version "1.1.5"
resolved "https://registry.yarnpkg.com/are-we-there-yet/-/are-we-there-yet-1.1.5.tgz#4b35c2944f062a8bfcda66410760350fe9ddfc21"
@@ -4111,6 +4182,11 @@ async@^2.6.2:
dependencies:
lodash "^4.17.14"
+async@^3.2.0:
+ version "3.2.0"
+ resolved "https://registry.yarnpkg.com/async/-/async-3.2.0.tgz#b3a2685c5ebb641d3de02d161002c60fc9f85720"
+ integrity sha512-TR2mEZFVOj2pLStYxLht7TyfuRzaydfpxr3k9RpHIzMgw7A64dzsdqCxH1WJyQdoe8T10nDXd9wnEigmiuHIZw==
+
asynckit@^0.4.0:
version "0.4.0"
resolved "https://registry.yarnpkg.com/asynckit/-/asynckit-0.4.0.tgz#c79ed97f7f34cb8f2ba1bc9790bcc366474b4b79"
@@ -4163,11 +4239,6 @@ babel-code-frame@^6.22.0:
esutils "^2.0.2"
js-tokens "^3.0.2"
-babel-core@7.0.0-bridge.0:
- version "7.0.0-bridge.0"
- resolved "https://registry.yarnpkg.com/babel-core/-/babel-core-7.0.0-bridge.0.tgz#95a492ddd90f9b4e9a4a1da14eb335b87b634ece"
- integrity sha512-poPX9mZH/5CSanm50Q+1toVci6pv5KSRv/5TWCwtzQS5XEwn40BcCrgIeMFWP9CKKIniKXNxoIOnOq4VVlGXhg==
-
babel-eslint@^10.0.3:
version "10.1.0"
resolved "https://registry.yarnpkg.com/babel-eslint/-/babel-eslint-10.1.0.tgz#6968e568a910b78fb3779cdd8b6ac2f479943232"
@@ -4688,7 +4759,7 @@ bindings@^1.5.0:
dependencies:
file-uri-to-path "1.0.0"
-bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5:
+bluebird@^3.3.5, bluebird@^3.5.1, bluebird@^3.5.3, bluebird@^3.5.5, bluebird@^3.7.2:
version "3.7.2"
resolved "https://registry.yarnpkg.com/bluebird/-/bluebird-3.7.2.tgz#9f229c15be272454ffa973ace0dbee79a1b0c36f"
integrity sha512-XpNj6GDQzdfW+r2Wnn7xiSAd7TM3jzkxGXBGTtWKuSXv1xUV+azxAm8jdWZN06QTQk+2N2XB9jRDkvbmQmcRtg==
@@ -4890,6 +4961,11 @@ btoa-lite@^1.0.0:
resolved "https://registry.yarnpkg.com/btoa-lite/-/btoa-lite-1.0.0.tgz#337766da15801210fdd956c22e9c6891ab9d0337"
integrity sha1-M3dm2hWAEhD92VbCLpxokaudAzc=
+buffer-crc32@~0.2.3:
+ version "0.2.13"
+ resolved "https://registry.yarnpkg.com/buffer-crc32/-/buffer-crc32-0.2.13.tgz#0d333e3f00eac50aa1454abd30ef8c2a5d9a7242"
+ integrity sha1-DTM+PwDqxQqhRUq9MO+MKl2ackI=
+
buffer-from@^1.0.0:
version "1.1.1"
resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.1.tgz#32713bc028f75c02fdb710d7c7bcec1f2c6070ef"
@@ -5019,6 +5095,11 @@ cache-base@^1.0.1:
union-value "^1.0.0"
unset-value "^1.0.0"
+cachedir@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/cachedir/-/cachedir-2.3.0.tgz#0c75892a052198f0b21c7c1804d8331edfcae0e8"
+ integrity sha512-A+Fezp4zxnit6FanDmv9EqXNAi3vt9DWp51/71UEhXukb7QUuvtv9344h91dyAxuTLoSYJFU299qzR3tzwPAhw==
+
call-me-maybe@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/call-me-maybe/-/call-me-maybe-1.0.1.tgz#26d208ea89e37b5cbde60250a15f031c16a4d66b"
@@ -5153,7 +5234,7 @@ chalk@2.4.2, chalk@^2.0.0, chalk@^2.0.1, chalk@^2.3.1, chalk@^2.4.1, chalk@^2.4.
escape-string-regexp "^1.0.5"
supports-color "^5.3.0"
-chalk@^1.1.3:
+chalk@^1.0.0, chalk@^1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/chalk/-/chalk-1.1.3.tgz#a8115c55e4a702fe4d150abd3872822a7e09fc98"
integrity sha1-qBFcVeSnAv5NFQq9OHKCKn4J/Jg=
@@ -5210,6 +5291,11 @@ chardet@^0.7.0:
resolved "https://registry.yarnpkg.com/chardet/-/chardet-0.7.0.tgz#90094849f0937f2eedc2425d0d28a9e5f0cbad9e"
integrity sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==
+check-more-types@^2.24.0:
+ version "2.24.0"
+ resolved "https://registry.yarnpkg.com/check-more-types/-/check-more-types-2.24.0.tgz#1420ffb10fd444dcfc79b43891bbfffd32a84600"
+ integrity sha1-FCD/sQ/URNz8ebQ4kbv//TKoRgA=
+
cheerio@^1.0.0-rc.3:
version "1.0.0-rc.3"
resolved "https://registry.yarnpkg.com/cheerio/-/cheerio-1.0.0-rc.3.tgz#094636d425b2e9c0f4eb91a46c05630c9a1a8bf6"
@@ -5313,7 +5399,14 @@ cli-boxes@^2.2.0:
resolved "https://registry.yarnpkg.com/cli-boxes/-/cli-boxes-2.2.0.tgz#538ecae8f9c6ca508e3c3c95b453fe93cb4c168d"
integrity sha512-gpaBrMAizVEANOpfZp/EEUixTXDyGt7DFzdK5hU+UbWt/J0lB0w20ncZj59Z9a93xHb9u12zF5BS6i9RKbtg4w==
-cli-cursor@^2.1.0:
+cli-cursor@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-1.0.2.tgz#64da3f7d56a54412e59794bd62dc35295e8f2987"
+ integrity sha1-ZNo/fValRBLll5S9Ytw1KV6PKYc=
+ dependencies:
+ restore-cursor "^1.0.1"
+
+cli-cursor@^2.0.0, cli-cursor@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/cli-cursor/-/cli-cursor-2.1.0.tgz#b35dac376479facc3e94747d41d0d0f5238ffcb5"
integrity sha1-s12sN2R5+sw+lHR9QdDQ9SOP/LU=
@@ -5327,7 +5420,7 @@ cli-cursor@^3.1.0:
dependencies:
restore-cursor "^3.1.0"
-cli-table3@0.5.1:
+cli-table3@0.5.1, cli-table3@~0.5.1:
version "0.5.1"
resolved "https://registry.yarnpkg.com/cli-table3/-/cli-table3-0.5.1.tgz#0252372d94dfc40dbd8df06005f48f31f656f202"
integrity sha512-7Qg2Jrep1S/+Q3EceiZtQcDPWxhAvBw+ERf1162v4sikJrvojMHFqXt8QIVha8UlH9rgU0BeWPytZ9/TzYqlUw==
@@ -5345,6 +5438,14 @@ cli-truncate@2.1.0, cli-truncate@^2.1.0:
slice-ansi "^3.0.0"
string-width "^4.2.0"
+cli-truncate@^0.2.1:
+ version "0.2.1"
+ resolved "https://registry.yarnpkg.com/cli-truncate/-/cli-truncate-0.2.1.tgz#9f15cfbb0705005369216c626ac7d05ab90dd574"
+ integrity sha1-nxXPuwcFAFNpIWxiasfQWrkN1XQ=
+ dependencies:
+ slice-ansi "0.0.4"
+ string-width "^1.0.1"
+
cli-width@^2.0.0:
version "2.2.1"
resolved "https://registry.yarnpkg.com/cli-width/-/cli-width-2.2.1.tgz#b0433d0b4e9c847ef18868a4ef16fd5fc8271c48"
@@ -5524,6 +5625,11 @@ commander@^5.1.0:
resolved "https://registry.yarnpkg.com/commander/-/commander-5.1.0.tgz#46abbd1652f8e059bddaef99bbdcb2ad9cf179ae"
integrity sha512-P0CysNDQ7rtVw4QIQtm+MRxV66vKFSvlsQvGYXZWR3qFU0jlMKHZZZgw8e+8DSah4UDKMqnknRDQz+xuQXQ/Zg==
+common-tags@^1.8.0:
+ version "1.8.0"
+ resolved "https://registry.yarnpkg.com/common-tags/-/common-tags-1.8.0.tgz#8e3153e542d4a39e9b10554434afaaf98956a937"
+ integrity sha512-6P6g0uetGpW/sdyUy/iQQCbFF0kWVMSIVSyYz7Zgjcgh8mgw8PQzDNZeyZ5DQ2gM7LBoZPHmnjz8rUthkBG5tw==
+
commondir@^1.0.1:
version "1.0.1"
resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b"
@@ -5572,7 +5678,7 @@ concat-map@0.0.1:
resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b"
integrity sha1-2Klr13/Wjfd5OnMDajug1UBdR3s=
-concat-stream@^1.5.0:
+concat-stream@^1.5.0, concat-stream@^1.6.2:
version "1.6.2"
resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34"
integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw==
@@ -6135,6 +6241,49 @@ cyclist@^1.0.1:
resolved "https://registry.yarnpkg.com/cyclist/-/cyclist-1.0.1.tgz#596e9698fd0c80e12038c2b82d6eb1b35b6224d9"
integrity sha1-WW6WmP0MgOEgOMK4LW6xs1tiJNk=
+cypress@^4.12.0:
+ version "4.12.0"
+ resolved "https://registry.yarnpkg.com/cypress/-/cypress-4.12.0.tgz#b9d9b16d45be28543edc0e4dc89987bed5bb090b"
+ integrity sha512-ZDngKMwoQ2UYmeSUJikLMZG6t2N7lTHHlzBzh5W0MbPfXSMv36YUgL2ZVD+t4ZLA63WWkvhwxIkDG+WJknBgHw==
+ dependencies:
+ "@cypress/listr-verbose-renderer" "^0.4.1"
+ "@cypress/request" "^2.88.5"
+ "@cypress/xvfb" "^1.2.4"
+ "@types/sinonjs__fake-timers" "^6.0.1"
+ "@types/sizzle" "^2.3.2"
+ arch "^2.1.2"
+ bluebird "^3.7.2"
+ cachedir "^2.3.0"
+ chalk "^2.4.2"
+ check-more-types "^2.24.0"
+ cli-table3 "~0.5.1"
+ commander "^4.1.1"
+ common-tags "^1.8.0"
+ debug "^4.1.1"
+ eventemitter2 "^6.4.2"
+ execa "^1.0.0"
+ executable "^4.1.1"
+ extract-zip "^1.7.0"
+ fs-extra "^8.1.0"
+ getos "^3.2.1"
+ is-ci "^2.0.0"
+ is-installed-globally "^0.3.2"
+ lazy-ass "^1.6.0"
+ listr "^0.14.3"
+ lodash "^4.17.19"
+ log-symbols "^3.0.0"
+ minimist "^1.2.5"
+ moment "^2.27.0"
+ ospath "^1.2.2"
+ pretty-bytes "^5.3.0"
+ ramda "~0.26.1"
+ request-progress "^3.0.0"
+ supports-color "^7.1.0"
+ tmp "~0.1.0"
+ untildify "^4.0.0"
+ url "^0.11.0"
+ yauzl "^2.10.0"
+
damerau-levenshtein@^1.0.4:
version "1.0.6"
resolved "https://registry.yarnpkg.com/damerau-levenshtein/-/damerau-levenshtein-1.0.6.tgz#143c1641cb3d85c60c32329e26899adea8701791"
@@ -6172,6 +6321,11 @@ data-urls@^2.0.0:
whatwg-mimetype "^2.3.0"
whatwg-url "^8.0.0"
+date-fns@^1.27.2:
+ version "1.30.1"
+ resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-1.30.1.tgz#2e71bf0b119153dbb4cc4e88d9ea5acfb50dc05c"
+ integrity sha512-hBSVCvSmWC+QypYObzwGOd9wqdDpOt+0wl0KbU+R+uuZBS1jN8VsD1ss3irQDknRj5NvxiTF6oj/nDRnN/UQNw==
+
date-fns@^2.4.1:
version "2.14.0"
resolved "https://registry.yarnpkg.com/date-fns/-/date-fns-2.14.0.tgz#359a87a265bb34ef2e38f93ecf63ac453f9bc7ba"
@@ -6714,6 +6868,11 @@ electron-to-chromium@^1.3.247, electron-to-chromium@^1.3.413:
resolved "https://registry.yarnpkg.com/electron-to-chromium/-/electron-to-chromium-1.3.442.tgz#62f96e0529f40a214a97411b57f27c4f080f0aa2"
integrity sha512-3OjmbnD9+LyWzh9o3rjC7LNIkcDHjKyHM6Xt0G/+7gHGCaEIwvWYi8TrNA8feNnuGmvI9WKu289PFMQGMLHAig==
+elegant-spinner@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/elegant-spinner/-/elegant-spinner-1.0.1.tgz#db043521c95d7e303fd8f345bedc3349cfb0729e"
+ integrity sha1-2wQ1IcldfjA/2PNFvtwzSc+wcp4=
+
element-resize-detector@^1.2.1:
version "1.2.1"
resolved "https://registry.yarnpkg.com/element-resize-detector/-/element-resize-detector-1.2.1.tgz#b0305194447a4863155e58f13323a0aef30851d1"
@@ -7092,6 +7251,13 @@ eslint-module-utils@^2.4.1:
debug "^2.6.9"
pkg-dir "^2.0.0"
+eslint-plugin-cypress@^2.11.1:
+ version "2.11.1"
+ resolved "https://registry.yarnpkg.com/eslint-plugin-cypress/-/eslint-plugin-cypress-2.11.1.tgz#a945e2774b88211e2c706a059d431e262b5c2862"
+ integrity sha512-MxMYoReSO5+IZMGgpBZHHSx64zYPSPTpXDwsgW7ChlJTF/sA+obqRbHplxD6sBStE+g4Mi0LCLkG4t9liu//mQ==
+ dependencies:
+ globals "^11.12.0"
+
eslint-plugin-flowtype@^4.3.0:
version "4.7.0"
resolved "https://registry.yarnpkg.com/eslint-plugin-flowtype/-/eslint-plugin-flowtype-4.7.0.tgz#903a6ea3eb5cbf4c7ba7fa73cc43fc39ab7e4a70"
@@ -7292,6 +7458,11 @@ etag@~1.8.1:
resolved "https://registry.yarnpkg.com/etag/-/etag-1.8.1.tgz#41ae2eeb65efa62268aebfea83ac7d79299b0887"
integrity sha1-Qa4u62XvpiJorr/qg6x9eSmbCIc=
+eventemitter2@^6.4.2:
+ version "6.4.3"
+ resolved "https://registry.yarnpkg.com/eventemitter2/-/eventemitter2-6.4.3.tgz#35c563619b13f3681e7eb05cbdaf50f56ba58820"
+ integrity sha512-t0A2msp6BzOf+QAcI6z9XMktLj52OjGQg+8SJH6v5+3uxNpWYRR3wQmfA+6xtMU9kOC59qk9licus5dYcrYkMQ==
+
eventemitter3@^3.1.0:
version "3.1.2"
resolved "https://registry.yarnpkg.com/eventemitter3/-/eventemitter3-3.1.2.tgz#2d3d48f9c346698fce83a85d7d664e98535df6e7"
@@ -7370,6 +7541,18 @@ execa@^4.0.1:
signal-exit "^3.0.2"
strip-final-newline "^2.0.0"
+executable@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/executable/-/executable-4.1.1.tgz#41532bff361d3e57af4d763b70582db18f5d133c"
+ integrity sha512-8iA79xD3uAch729dUG8xaaBBFGaEa0wdD2VkYLFHwlqosEj/jT66AzcreRDSgV7ehnNLBW2WR5jIXwGKjVdTLg==
+ dependencies:
+ pify "^2.2.0"
+
+exit-hook@^1.0.0:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/exit-hook/-/exit-hook-1.1.1.tgz#f05ca233b48c05d54fff07765df8507e95c02ff8"
+ integrity sha1-8FyiM7SMBdVP/wd2XfhQfpXAL/g=
+
exit@^0.1.2:
version "0.1.2"
resolved "https://registry.yarnpkg.com/exit/-/exit-0.1.2.tgz#0632638f8d877cc82107d30a0fff1a17cba1cd0c"
@@ -7498,6 +7681,16 @@ extglob@^2.0.4:
snapdragon "^0.8.1"
to-regex "^3.0.1"
+extract-zip@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/extract-zip/-/extract-zip-1.7.0.tgz#556cc3ae9df7f452c493a0cfb51cc30277940927"
+ integrity sha512-xoh5G1W/PB0/27lXgMQyIhP5DSY/LhoCsOyZgb+6iMmRtCwVBo55uKaMoEYrDCKQhWvqEip5ZPKAc6eFNyf/MA==
+ dependencies:
+ concat-stream "^1.6.2"
+ debug "^2.6.9"
+ mkdirp "^0.5.4"
+ yauzl "^2.10.0"
+
extsprintf@1.3.0:
version "1.3.0"
resolved "https://registry.yarnpkg.com/extsprintf/-/extsprintf-1.3.0.tgz#96918440e3041a7a414f8c52e3c574eb3c3e1e05"
@@ -7545,7 +7738,7 @@ fast-levenshtein@^2.0.6, fast-levenshtein@~2.0.6:
resolved "https://registry.yarnpkg.com/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz#3d8a5c66883a16a30ca8643e851f19baa7797917"
integrity sha1-PYpcZog6FqMMqGQ+hR8Zuqd5eRc=
-fault@^1.0.0:
+fault@^1.0.0, fault@^1.0.2:
version "1.0.4"
resolved "https://registry.yarnpkg.com/fault/-/fault-1.0.4.tgz#eafcfc0a6d214fc94601e170df29954a4f842f13"
integrity sha512-CJ0HCB5tL5fYTEA7ToAq5+kTwd++Borf1/bifxd9iT70QcXr4MRrO3Llf8Ifs70q+SJcGHFtnIE/Nw6giCtECA==
@@ -7586,6 +7779,13 @@ fbjs@^0.8.1:
setimmediate "^1.0.5"
ua-parser-js "^0.7.18"
+fd-slicer@~1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/fd-slicer/-/fd-slicer-1.1.0.tgz#25c7c89cb1f9077f8891bbe61d8f390eae256f1e"
+ integrity sha1-JcfInLH5B3+IkbvmHY85Dq4lbx4=
+ dependencies:
+ pend "~1.2.0"
+
fetch-mock@^7.5.1:
version "7.7.3"
resolved "https://registry.yarnpkg.com/fetch-mock/-/fetch-mock-7.7.3.tgz#6a3f94cfed6e423ab7f5464912982030da605335"
@@ -7603,6 +7803,14 @@ figgy-pudding@^3.4.1, figgy-pudding@^3.5.1:
resolved "https://registry.yarnpkg.com/figgy-pudding/-/figgy-pudding-3.5.2.tgz#b4eee8148abb01dcf1d1ac34367d59e12fa61d6e"
integrity sha512-0btnI/H8f2pavGMN8w40mlSKOfTK2SVJmBfBeVIj3kNw0swwgzyRq0d5TJVOwodFmtvpPeWPN/MCcfuWF0Ezbw==
+figures@^1.7.0:
+ version "1.7.0"
+ resolved "https://registry.yarnpkg.com/figures/-/figures-1.7.0.tgz#cbe1e3affcf1cd44b80cadfed28dc793a9701d2e"
+ integrity sha1-y+Hjr/zxzUS4DK3+0o3Hk6lwHS4=
+ dependencies:
+ escape-string-regexp "^1.0.5"
+ object-assign "^4.1.0"
+
figures@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/figures/-/figures-2.0.0.tgz#3ab1a2d2a62c8bfb431a0c94cb797a2fce27c962"
@@ -8035,6 +8243,13 @@ get-value@^2.0.3, get-value@^2.0.6:
resolved "https://registry.yarnpkg.com/get-value/-/get-value-2.0.6.tgz#dc15ca1c672387ca76bd37ac0a395ba2042a2c28"
integrity sha1-3BXKHGcjh8p2vTesCjlbogQqLCg=
+getos@^3.2.1:
+ version "3.2.1"
+ resolved "https://registry.yarnpkg.com/getos/-/getos-3.2.1.tgz#0134d1f4e00eb46144c5a9c0ac4dc087cbb27dc5"
+ integrity sha512-U56CfOK17OKgTVqozZjUKNdkfEv6jk5WISBJ8SHoagjE6L69zOwl3Z+O8myjY9MEW3i2HPWQBt/LTbCgcC973Q==
+ dependencies:
+ async "^3.2.0"
+
getpass@^0.1.1:
version "0.1.7"
resolved "https://registry.yarnpkg.com/getpass/-/getpass-0.1.7.tgz#5eff8e3e684d569ae4cb2b1282604e8ba62149fa"
@@ -8091,7 +8306,7 @@ gitconfiglocal@^1.0.0:
dependencies:
ini "^1.3.2"
-gitdiff-parser@^0.1.2, "gitdiff-parser@https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d":
+gitdiff-parser@^0.1.2:
version "0.1.2"
resolved "https://github.com/scm-manager/gitdiff-parser#617747460280bf4522bb84d217a9064ac8eb6d3d"
@@ -8147,6 +8362,13 @@ glob@^7.0.0, glob@^7.0.3, glob@^7.1.1, glob@^7.1.2, glob@^7.1.3, glob@^7.1.4, gl
once "^1.3.0"
path-is-absolute "^1.0.0"
+global-dirs@^2.0.1:
+ version "2.0.1"
+ resolved "https://registry.yarnpkg.com/global-dirs/-/global-dirs-2.0.1.tgz#acdf3bb6685bcd55cb35e8a052266569e9469201"
+ integrity sha512-5HqUqdhkEovj2Of/ms3IeS/EekcO54ytHRLV4PEY2rhRwrHXLQjeVEES0Lhka0xwNDtGYn58wyC4s5+MHsOO6A==
+ dependencies:
+ ini "^1.3.5"
+
global-modules@2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/global-modules/-/global-modules-2.0.0.tgz#997605ad2345f27f51539bea26574421215c7780"
@@ -8191,7 +8413,7 @@ global@^4.3.2, global@^4.4.0:
min-document "^2.19.0"
process "^0.11.10"
-globals@^11.1.0:
+globals@^11.1.0, globals@^11.12.0:
version "11.12.0"
resolved "https://registry.yarnpkg.com/globals/-/globals-11.12.0.tgz#ab8795338868a0babd8525758018c2a7eb95c42e"
integrity sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==
@@ -9265,6 +9487,14 @@ is-hexadecimal@^1.0.0:
resolved "https://registry.yarnpkg.com/is-hexadecimal/-/is-hexadecimal-1.0.4.tgz#cc35c97588da4bd49a8eedd6bc4082d44dcb23a7"
integrity sha512-gyPJuv83bHMpocVYoqof5VDiZveEoGoFL8m3BXNb2VW8Xs+rz9kqO8LOQ5DH6EsuvilT1ApazU0pyl+ytbPtlw==
+is-installed-globally@^0.3.2:
+ version "0.3.2"
+ resolved "https://registry.yarnpkg.com/is-installed-globally/-/is-installed-globally-0.3.2.tgz#fd3efa79ee670d1187233182d5b0a1dd00313141"
+ integrity sha512-wZ8x1js7Ia0kecP/CHM/3ABkAmujX7WPvQk6uu3Fly/Mk44pySulQpnHG46OMjHGXApINnV4QhY3SWnECO2z5g==
+ dependencies:
+ global-dirs "^2.0.1"
+ is-path-inside "^3.0.1"
+
is-map@^2.0.1:
version "2.0.1"
resolved "https://registry.yarnpkg.com/is-map/-/is-map-2.0.1.tgz#520dafc4307bb8ebc33b813de5ce7c9400d644a1"
@@ -9302,6 +9532,13 @@ is-object@^1.0.1:
resolved "https://registry.yarnpkg.com/is-object/-/is-object-1.0.1.tgz#8952688c5ec2ffd6b03ecc85e769e02903083470"
integrity sha1-iVJojF7C/9awPsyF52ngKQMINHA=
+is-observable@^1.1.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/is-observable/-/is-observable-1.1.0.tgz#b3e986c8f44de950867cab5403f5a3465005975e"
+ integrity sha512-NqCa4Sa2d+u7BWc6CukaObG3Fh+CU9bvixbpcXYhy2VvYS7vVGIdAgnIS5Ks3A/cqk4rebLJ9s8zBstT2aKnIA==
+ dependencies:
+ symbol-observable "^1.1.0"
+
is-path-cwd@^2.0.0:
version "2.2.0"
resolved "https://registry.yarnpkg.com/is-path-cwd/-/is-path-cwd-2.2.0.tgz#67d43b82664a7b5191fd9119127eb300048a9fdb"
@@ -9321,6 +9558,11 @@ is-path-inside@^2.1.0:
dependencies:
path-is-inside "^1.0.2"
+is-path-inside@^3.0.1:
+ version "3.0.2"
+ resolved "https://registry.yarnpkg.com/is-path-inside/-/is-path-inside-3.0.2.tgz#f5220fc82a3e233757291dddc9c5877f2a1f3017"
+ integrity sha512-/2UGPSgmtqwo1ktx8NDHjuPwZWmHhO+gj0f93EkhLB5RgW9RZevWYYlIkS6zePc6U2WpOdQYIwHe9YC4DWEBVg==
+
is-plain-obj@^1.0.0, is-plain-obj@^1.1.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/is-plain-obj/-/is-plain-obj-1.1.0.tgz#71a50c8429dfca773c92a390a4a03b39fcd51d3e"
@@ -9345,6 +9587,11 @@ is-potential-custom-element-name@^1.0.0:
resolved "https://registry.yarnpkg.com/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.0.tgz#0c52e54bcca391bb2c494b21e8626d7336c6e397"
integrity sha1-DFLlS8yjkbssSUsh6GJtczbG45c=
+is-promise@^2.1.0:
+ version "2.2.2"
+ resolved "https://registry.yarnpkg.com/is-promise/-/is-promise-2.2.2.tgz#39ab959ccbf9a774cf079f7b40c7a26f763135f1"
+ integrity sha512-+lP4/6lKUBfQjZ2pdxThZvLUAafmZb8OAxFb8XXtiQmS35INgr85hdOGoEs124ez1FCnZJt6jau/T+alh58QFQ==
+
is-regex@^1.0.4, is-regex@^1.0.5:
version "1.0.5"
resolved "https://registry.yarnpkg.com/is-regex/-/is-regex-1.0.5.tgz#39d589a358bf18967f726967120b8fc1aed74eae"
@@ -10594,6 +10841,11 @@ last-call-webpack-plugin@^3.0.0:
lodash "^4.17.5"
webpack-sources "^1.1.0"
+lazy-ass@^1.6.0:
+ version "1.6.0"
+ resolved "https://registry.yarnpkg.com/lazy-ass/-/lazy-ass-1.6.0.tgz#7999655e8646c17f089fdd187d150d3324d54513"
+ integrity sha1-eZllXoZGwX8In90YfRUNMyTVRRM=
+
lazy-cache@^0.2.3:
version "0.2.7"
resolved "https://registry.yarnpkg.com/lazy-cache/-/lazy-cache-0.2.7.tgz#7feddf2dcb6edb77d11ef1d117ab5ffdf0ab1b65"
@@ -10705,6 +10957,35 @@ lint-staged@^10.2.11:
string-argv "0.3.1"
stringify-object "^3.3.0"
+listr-silent-renderer@^1.1.1:
+ version "1.1.1"
+ resolved "https://registry.yarnpkg.com/listr-silent-renderer/-/listr-silent-renderer-1.1.1.tgz#924b5a3757153770bf1a8e3fbf74b8bbf3f9242e"
+ integrity sha1-kktaN1cVN3C/Go4/v3S4u/P5JC4=
+
+listr-update-renderer@^0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/listr-update-renderer/-/listr-update-renderer-0.5.0.tgz#4ea8368548a7b8aecb7e06d8c95cb45ae2ede6a2"
+ integrity sha512-tKRsZpKz8GSGqoI/+caPmfrypiaq+OQCbd+CovEC24uk1h952lVj5sC7SqyFUm+OaJ5HN/a1YLt5cit2FMNsFA==
+ dependencies:
+ chalk "^1.1.3"
+ cli-truncate "^0.2.1"
+ elegant-spinner "^1.0.1"
+ figures "^1.7.0"
+ indent-string "^3.0.0"
+ log-symbols "^1.0.2"
+ log-update "^2.3.0"
+ strip-ansi "^3.0.1"
+
+listr-verbose-renderer@^0.5.0:
+ version "0.5.0"
+ resolved "https://registry.yarnpkg.com/listr-verbose-renderer/-/listr-verbose-renderer-0.5.0.tgz#f1132167535ea4c1261102b9f28dac7cba1e03db"
+ integrity sha512-04PDPqSlsqIOaaaGZ+41vq5FejI9auqTInicFRndCBgE3bXG8D6W1I+mWhk+1nqbHmyhla/6BUrd5OSiHwKRXw==
+ dependencies:
+ chalk "^2.4.1"
+ cli-cursor "^2.1.0"
+ date-fns "^1.27.2"
+ figures "^2.0.0"
+
listr2@^2.1.0:
version "2.4.1"
resolved "https://registry.yarnpkg.com/listr2/-/listr2-2.4.1.tgz#006fc94ae77b3195403cbf3a4a563e2d6366224f"
@@ -10719,6 +11000,21 @@ listr2@^2.1.0:
rxjs "^6.6.0"
through "^2.3.8"
+listr@^0.14.3:
+ version "0.14.3"
+ resolved "https://registry.yarnpkg.com/listr/-/listr-0.14.3.tgz#2fea909604e434be464c50bddba0d496928fa586"
+ integrity sha512-RmAl7su35BFd/xoMamRjpIE4j3v+L28o8CT5YhAXQJm1fD+1l9ngXY8JAQRJ+tFK2i5njvi0iRUKV09vPwA0iA==
+ dependencies:
+ "@samverschueren/stream-to-observable" "^0.3.0"
+ is-observable "^1.1.0"
+ is-promise "^2.1.0"
+ is-stream "^1.1.0"
+ listr-silent-renderer "^1.1.1"
+ listr-update-renderer "^0.5.0"
+ listr-verbose-renderer "^0.5.0"
+ p-map "^2.0.0"
+ rxjs "^6.3.3"
+
load-json-file@^1.0.0:
version "1.1.0"
resolved "https://registry.yarnpkg.com/load-json-file/-/load-json-file-1.1.0.tgz#956905708d58b4bab4c2261b04f59f31c99374c0"
@@ -10881,6 +11177,11 @@ lodash.merge@^4.6.2:
resolved "https://registry.yarnpkg.com/lodash.merge/-/lodash.merge-4.6.2.tgz#558aa53b43b661e1925a0afdfa36a9a1085fe57a"
integrity sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==
+lodash.once@^4.1.1:
+ version "4.1.1"
+ resolved "https://registry.yarnpkg.com/lodash.once/-/lodash.once-4.1.1.tgz#0dd3971213c7c56df880977d504c88fb471a97ac"
+ integrity sha1-DdOXEhPHxW34gJd9UEyI+0cal6w=
+
lodash.set@^4.3.2:
version "4.3.2"
resolved "https://registry.yarnpkg.com/lodash.set/-/lodash.set-4.3.2.tgz#d8757b1da807dde24816b0d6a84bea1a76230b23"
@@ -10921,6 +11222,25 @@ lodash@^4.15.0, lodash@^4.17.11, lodash@^4.17.12, lodash@^4.17.13, lodash@^4.17.
resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.15.tgz#b447f6670a0455bbfeedd11392eff330ea097548"
integrity sha512-8xOcRHvCjnocdS5cpwXQXVzmmh5e5+saE2QGoeQmbKmRS6J3VQppPOIt0MnmE+4xlZoumy0GPG0D0MVIQbNA1A==
+lodash@^4.17.19:
+ version "4.17.19"
+ resolved "https://registry.yarnpkg.com/lodash/-/lodash-4.17.19.tgz#e48ddedbe30b3321783c5b4301fbd353bc1e4a4b"
+ integrity sha512-JNvd8XER9GQX0v2qJgsaN/mzFCNA5BRe/j8JN9d+tWyGLSodKQHKFicdwNYzWwI3wjRnaKPsGj1XkBjx/F96DQ==
+
+log-symbols@^1.0.2:
+ version "1.0.2"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-1.0.2.tgz#376ff7b58ea3086a0f09facc74617eca501e1a18"
+ integrity sha1-N2/3tY6jCGoPCfrMdGF+ylAeGhg=
+ dependencies:
+ chalk "^1.0.0"
+
+log-symbols@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-3.0.0.tgz#f3a08516a5dea893336a7dee14d18a1cfdab77c4"
+ integrity sha512-dSkNGuI7iG3mfvDzUuYZyvk5dD9ocYCYzNU6CYDE6+Xqd+gwme6Z00NS3dUh8mq/73HaEtT7m6W+yUPtU6BZnQ==
+ dependencies:
+ chalk "^2.4.2"
+
log-symbols@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/log-symbols/-/log-symbols-4.0.0.tgz#69b3cc46d20f448eccdb75ea1fa733d9e821c920"
@@ -10928,6 +11248,15 @@ log-symbols@^4.0.0:
dependencies:
chalk "^4.0.0"
+log-update@^2.3.0:
+ version "2.3.0"
+ resolved "https://registry.yarnpkg.com/log-update/-/log-update-2.3.0.tgz#88328fd7d1ce7938b29283746f0b1bc126b24708"
+ integrity sha1-iDKP19HOeTiykoN0bwsbwSayRwg=
+ dependencies:
+ ansi-escapes "^3.0.0"
+ cli-cursor "^2.0.0"
+ wrap-ansi "^3.0.1"
+
log-update@^4.0.0:
version "4.0.0"
resolved "https://registry.yarnpkg.com/log-update/-/log-update-4.0.0.tgz#589ecd352471f2a1c0c570287543a64dfd20e0a1"
@@ -10965,7 +11294,7 @@ lower-case@^2.0.1:
dependencies:
tslib "^1.10.0"
-lowlight@1.13.1, lowlight@^1.13.0, lowlight@~1.11.0:
+lowlight@^1.13.0:
version "1.13.1"
resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.13.1.tgz#c4f0e03906ebd23fedf2d258f6ab2f6324cf90eb"
integrity sha512-kQ71/T6RksEVz9AlPq07/2m+SU/1kGvt9k39UtvHX760u4SaWakaYH7hYgH5n6sTsCWk4MVYzUzLU59aN5CSmQ==
@@ -10973,6 +11302,14 @@ lowlight@1.13.1, lowlight@^1.13.0, lowlight@~1.11.0:
fault "^1.0.0"
highlight.js "~9.16.0"
+lowlight@~1.11.0:
+ version "1.11.0"
+ resolved "https://registry.yarnpkg.com/lowlight/-/lowlight-1.11.0.tgz#1304d83005126d4e8b1dc0f07981e9b689ec2efc"
+ integrity sha512-xrGGN6XLL7MbTMdPD6NfWPwY43SNkjf/d0mecSx/CW36fUZTjRHEq0/Cdug3TWKtRXLWi7iMl1eP0olYxj/a4A==
+ dependencies:
+ fault "^1.0.2"
+ highlight.js "~9.13.0"
+
lru-cache@^5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/lru-cache/-/lru-cache-5.1.1.tgz#1da27e6710271947695daf6848e847f01d84b920"
@@ -11468,7 +11805,7 @@ mkdirp@*:
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-1.0.4.tgz#3eb5ed62622756d79a5f0e2a221dfebad75c2f7e"
integrity sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
-mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@~0.5.1:
+mkdirp@^0.5.0, mkdirp@^0.5.1, mkdirp@^0.5.3, mkdirp@^0.5.4, mkdirp@~0.5.1:
version "0.5.5"
resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.5.tgz#d91cefd62d1436ca0f41620e251288d420099def"
integrity sha512-NKmAlESf6jMGym1++R0Ra7wvhV+wFW63FaSOFPwRahvea0gMUcGUhVeAg/0BC0wiv9ih5NYPB1Wn1UEI1/L+xQ==
@@ -11480,6 +11817,11 @@ modify-values@^1.0.0:
resolved "https://registry.yarnpkg.com/modify-values/-/modify-values-1.0.1.tgz#b3939fa605546474e3e3e3c63d64bd43b4ee6022"
integrity sha512-xV2bxeN6F7oYjZWTe/YPAy6MN2M+sL4u/Rlm2AHCIVGfo2p1yGmBHQ6vHehl4bRTZBdHu3TSkWdYgkwpYzAGSw==
+moment@^2.27.0:
+ version "2.27.0"
+ resolved "https://registry.yarnpkg.com/moment/-/moment-2.27.0.tgz#8bff4e3e26a236220dfe3e36de756b6ebaa0105d"
+ integrity sha512-al0MUK7cpIcglMv3YF13qSgdAIqxHTO7brRtaz3DlSULbqfazqkc5kEjNrLDOM7fsjshoFIihnU8snrP7zUvhQ==
+
moo@^0.5.0:
version "0.5.1"
resolved "https://registry.yarnpkg.com/moo/-/moo-0.5.1.tgz#7aae7f384b9b09f620b6abf6f74ebbcd1b65dbc4"
@@ -12039,6 +12381,11 @@ once@^1.3.0, once@^1.3.1, once@^1.4.0:
dependencies:
wrappy "1"
+onetime@^1.0.0:
+ version "1.1.0"
+ resolved "https://registry.yarnpkg.com/onetime/-/onetime-1.1.0.tgz#a1f7838f8314c516f05ecefcbc4ccfe04b4ed789"
+ integrity sha1-ofeDj4MUxRbwXs78vEzP4EtO14k=
+
onetime@^2.0.0:
version "2.0.1"
resolved "https://registry.yarnpkg.com/onetime/-/onetime-2.0.1.tgz#067428230fd67443b2794b22bba528b6867962d4"
@@ -12159,6 +12506,11 @@ osenv@^0.1.4, osenv@^0.1.5:
os-homedir "^1.0.0"
os-tmpdir "^1.0.0"
+ospath@^1.2.2:
+ version "1.2.2"
+ resolved "https://registry.yarnpkg.com/ospath/-/ospath-1.2.2.tgz#1276639774a3f8ef2572f7fe4280e0ea4550c07b"
+ integrity sha1-EnZjl3Sj+O8lcvf+QoDg6kVQwHs=
+
p-defer@^1.0.0:
version "1.0.0"
resolved "https://registry.yarnpkg.com/p-defer/-/p-defer-1.0.0.tgz#9f6eb182f6c9aa8cd743004a7d4f96b196b0fb0c"
@@ -12537,6 +12889,11 @@ pbkdf2@^3.0.3:
safe-buffer "^5.0.1"
sha.js "^2.4.8"
+pend@~1.2.0:
+ version "1.2.0"
+ resolved "https://registry.yarnpkg.com/pend/-/pend-1.2.0.tgz#7a57eb550a6783f9115331fcf4663d5c8e007a50"
+ integrity sha1-elfrVQpng/kRUzH89GY9XI4AelA=
+
performance-now@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/performance-now/-/performance-now-2.1.0.tgz#6309f4e0e5fa913ec1c69307ae364b4b377c9e7b"
@@ -12547,7 +12904,7 @@ picomatch@^2.0.4, picomatch@^2.0.5, picomatch@^2.2.1:
resolved "https://registry.yarnpkg.com/picomatch/-/picomatch-2.2.2.tgz#21f333e9b6b8eaff02468f5146ea406d345f4dad"
integrity sha512-q0M/9eZHzmr0AulXyPwNfZjtwZ/RBZlbN3K3CErVrk50T2ASYI7Bye0EvekFY3IP1Nt2DHu0re+V2ZHIpMkuWg==
-pify@^2.0.0, pify@^2.3.0:
+pify@^2.0.0, pify@^2.2.0, pify@^2.3.0:
version "2.3.0"
resolved "https://registry.yarnpkg.com/pify/-/pify-2.3.0.tgz#ed141a6ac043a849ea588498e7dca8b15330e90c"
integrity sha1-7RQaasBDqEnqWISY59yosVMw6Qw=
@@ -13028,6 +13385,11 @@ prettier@^1.19.1:
resolved "https://registry.yarnpkg.com/prettier/-/prettier-1.19.1.tgz#f7d7f5ff8a9cd872a7be4ca142095956a60797cb"
integrity sha512-s7PoyDv/II1ObgQunCbB9PdLmUcBZcnWOcxDh7O0N/UwDEsHyqkW+Qh28jW+mVuCdx7gLB0BotYI1Y6uI9iyew==
+pretty-bytes@^5.3.0:
+ version "5.3.0"
+ resolved "https://registry.yarnpkg.com/pretty-bytes/-/pretty-bytes-5.3.0.tgz#f2849e27db79fb4d6cfe24764fc4134f165989f2"
+ integrity sha512-hjGrh+P926p4R4WbaB6OckyRtO0F0/lQBiT+0gnxjV+5kjPBrfVBFCsCLbMqVQeydvIoouYTCmmEURiH3R1Bdg==
+
pretty-error@^2.1.1:
version "2.1.1"
resolved "https://registry.yarnpkg.com/pretty-error/-/pretty-error-2.1.1.tgz#5f4f87c8f91e5ae3f3ba87ab4cf5e03b1a17f1a3"
@@ -13351,7 +13713,7 @@ ramda@^0.21.0:
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.21.0.tgz#a001abedb3ff61077d4ff1d577d44de77e8d0a35"
integrity sha1-oAGr7bP/YQd9T/HVd9RN536NCjU=
-ramda@^0.26:
+ramda@^0.26, ramda@~0.26.1:
version "0.26.1"
resolved "https://registry.yarnpkg.com/ramda/-/ramda-0.26.1.tgz#8d41351eb8111c55353617fc3bbffad8e4d35d06"
integrity sha512-hLWjpy7EnsDBb0p+Z3B7rPi3GDeRG5ZtiI33kJhTt+ORCd38AbAIjB/9zRIUoeTbE/AVX5ZkU7m6bznsvrf8eQ==
@@ -14161,6 +14523,13 @@ replace-ext@1.0.0:
resolved "https://registry.yarnpkg.com/replace-ext/-/replace-ext-1.0.0.tgz#de63128373fcbf7c3ccfa4de5a480c45a67958eb"
integrity sha1-3mMSg3P8v3w8z6TeWkgMRaZ5WOs=
+request-progress@^3.0.0:
+ version "3.0.0"
+ resolved "https://registry.yarnpkg.com/request-progress/-/request-progress-3.0.0.tgz#4ca754081c7fec63f505e4faa825aa06cd669dbe"
+ integrity sha1-TKdUCBx/7GP1BeT6qCWqBs1mnb4=
+ dependencies:
+ throttleit "^1.0.0"
+
request-promise-core@1.1.3:
version "1.1.3"
resolved "https://registry.yarnpkg.com/request-promise-core/-/request-promise-core-1.1.3.tgz#e9a3c081b51380dfea677336061fea879a829ee9"
@@ -14282,6 +14651,14 @@ resolve@^1.1.6, resolve@^1.10.0, resolve@^1.11.0, resolve@^1.12.0, resolve@^1.13
dependencies:
path-parse "^1.0.6"
+restore-cursor@^1.0.1:
+ version "1.0.1"
+ resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-1.0.1.tgz#34661f46886327fed2991479152252df92daa541"
+ integrity sha1-NGYfRohjJ/7SmRR5FSJS35LapUE=
+ dependencies:
+ exit-hook "^1.0.0"
+ onetime "^1.0.0"
+
restore-cursor@^2.0.0:
version "2.0.0"
resolved "https://registry.yarnpkg.com/restore-cursor/-/restore-cursor-2.0.0.tgz#9f7ee287f82fd326d4fd162923d62129eee0dfaf"
@@ -14377,6 +14754,13 @@ run-queue@^1.0.0, run-queue@^1.0.3:
dependencies:
aproba "^1.1.1"
+rxjs@^6.3.3, rxjs@^6.6.0:
+ version "6.6.2"
+ resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2"
+ integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==
+ dependencies:
+ tslib "^1.9.0"
+
rxjs@^6.4.0, rxjs@^6.5.3:
version "6.5.5"
resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.5.5.tgz#c5c884e3094c8cfee31bf27eb87e54ccfc87f9ec"
@@ -14384,13 +14768,6 @@ rxjs@^6.4.0, rxjs@^6.5.3:
dependencies:
tslib "^1.9.0"
-rxjs@^6.6.0:
- version "6.6.2"
- resolved "https://registry.yarnpkg.com/rxjs/-/rxjs-6.6.2.tgz#8096a7ac03f2cc4fe5860ef6e572810d9e01c0d2"
- integrity sha512-BHdBMVoWC2sL26w//BCu3YzKT4s2jip/WhwsGEDmeKYBhKDZeYezVUnHatYB7L85v5xs0BAQmg6BEYJEKxBabg==
- dependencies:
- tslib "^1.9.0"
-
safe-buffer@5.1.1:
version "5.1.1"
resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.1.tgz#893312af69b2123def71f57889001671eeb2c853"
@@ -14773,6 +15150,11 @@ slash@^3.0.0:
resolved "https://registry.yarnpkg.com/slash/-/slash-3.0.0.tgz#6539be870c165adbd5240220dbe361f1bc4d4634"
integrity sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==
+slice-ansi@0.0.4:
+ version "0.0.4"
+ resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-0.0.4.tgz#edbf8903f66f7ce2f8eafd6ceed65e264c831b35"
+ integrity sha1-7b+JA/ZvfOL46v1s7tZeJkyDGzU=
+
slice-ansi@^2.1.0:
version "2.1.0"
resolved "https://registry.yarnpkg.com/slice-ansi/-/slice-ansi-2.1.0.tgz#cacd7693461a637a5788d92a7dd4fba068e81636"
@@ -15484,7 +15866,7 @@ svgo@^1.0.0, svgo@^1.2.2:
unquote "~1.1.1"
util.promisify "~1.0.0"
-symbol-observable@^1.0.3, symbol-observable@^1.0.4, symbol-observable@^1.2.0:
+symbol-observable@^1.0.3, symbol-observable@^1.0.4, symbol-observable@^1.1.0, symbol-observable@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/symbol-observable/-/symbol-observable-1.2.0.tgz#c22688aed4eab3cdc2dfeacbb561660560a00804"
integrity sha512-e900nM8RRtGhlV36KGEU9k65K3mPb1WV70OdjfxlG2EAuM1noi/E/BaW/uMhL7bPEssK8QV57vN3esixjUvcXQ==
@@ -15676,6 +16058,11 @@ throttle-debounce@^2.1.0:
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-2.1.0.tgz#257e648f0a56bd9e54fe0f132c4ab8611df4e1d5"
integrity sha512-AOvyNahXQuU7NN+VVvOOX+uW6FPaWdAOdRP5HfwYxAfCzXTFKRMoIMk+n+po318+ktcChx+F1Dd91G3YHeMKyg==
+throttleit@^1.0.0:
+ version "1.0.0"
+ resolved "https://registry.yarnpkg.com/throttleit/-/throttleit-1.0.0.tgz#9e785836daf46743145a5984b6268d828528ac6c"
+ integrity sha1-nnhYNtr0Z0MUWlmEtiaNgoUorGw=
+
through2@^2.0.0, through2@^2.0.2:
version "2.0.5"
resolved "https://registry.yarnpkg.com/through2/-/through2-2.0.5.tgz#01c1e39eb31d07cb7d03a96a70823260b23132cd"
@@ -15735,6 +16122,13 @@ tmp@^0.0.33:
dependencies:
os-tmpdir "~1.0.2"
+tmp@~0.1.0:
+ version "0.1.0"
+ resolved "https://registry.yarnpkg.com/tmp/-/tmp-0.1.0.tgz#ee434a4e22543082e294ba6201dcc6eafefa2877"
+ integrity sha512-J7Z2K08jbGcdA1kkQpJSqLF6T0tdQqpR2pnSUXsIchbPdTI9v3e85cLW0d6WDhwuAleOV71j2xWs8qMPfK7nKw==
+ dependencies:
+ rimraf "^2.6.3"
+
tmpl@1.0.x:
version "1.0.4"
resolved "https://registry.yarnpkg.com/tmpl/-/tmpl-1.0.4.tgz#23640dd7b42d00433911140820e5cf440e521dd1"
@@ -16155,6 +16549,11 @@ unset-value@^1.0.0:
has-value "^0.3.1"
isobject "^3.0.0"
+untildify@^4.0.0:
+ version "4.0.0"
+ resolved "https://registry.yarnpkg.com/untildify/-/untildify-4.0.0.tgz#2bc947b953652487e4600949fb091e3ae8cd919b"
+ integrity sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==
+
upath@^1.1.1, upath@^1.2.0:
version "1.2.0"
resolved "https://registry.yarnpkg.com/upath/-/upath-1.2.0.tgz#8f66dbcd55a883acdae4408af8b035a5044c1894"
@@ -16714,6 +17113,14 @@ worker-rpc@^0.1.0:
dependencies:
microevent.ts "~0.1.1"
+wrap-ansi@^3.0.1:
+ version "3.0.1"
+ resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-3.0.1.tgz#288a04d87eda5c286e060dfe8f135ce8d007f8ba"
+ integrity sha1-KIoE2H7aXChuBg3+jxNc6NAH+Lo=
+ dependencies:
+ string-width "^2.1.1"
+ strip-ansi "^4.0.0"
+
wrap-ansi@^5.1.0:
version "5.1.0"
resolved "https://registry.yarnpkg.com/wrap-ansi/-/wrap-ansi-5.1.0.tgz#1fd1f67235d5b6d0fee781056001bfb694c03b09"
@@ -16965,3 +17372,11 @@ yargs@^15.3.1:
which-module "^2.0.0"
y18n "^4.0.0"
yargs-parser "^18.1.1"
+
+yauzl@^2.10.0:
+ version "2.10.0"
+ resolved "https://registry.yarnpkg.com/yauzl/-/yauzl-2.10.0.tgz#c7eb17c93e112cb1086fa6d8e51fb0667b79a5f9"
+ integrity sha1-x+sXyT4RLLEIb6bY5R+wZnt5pfk=
+ dependencies:
+ buffer-crc32 "~0.2.3"
+ fd-slicer "~1.1.0"