mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-02 03:25:56 +01:00
show signature key on changeset
This commit is contained in:
@@ -5,5 +5,5 @@
|
||||
],
|
||||
"npmClient": "yarn",
|
||||
"useWorkspaces": true,
|
||||
"version": "2.3.0"
|
||||
"version": "2.4.0-SNAPSHOT"
|
||||
}
|
||||
|
||||
@@ -30,6 +30,7 @@ import de.otto.edison.hal.Links;
|
||||
import lombok.Getter;
|
||||
import lombok.NoArgsConstructor;
|
||||
import lombok.Setter;
|
||||
import sonia.scm.repository.Signature;
|
||||
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
@@ -61,6 +62,8 @@ public class ChangesetDto extends HalRepresentation {
|
||||
|
||||
private List<ContributorDto> contributors;
|
||||
|
||||
private List<Signature> signatures;
|
||||
|
||||
public ChangesetDto(Links links, Embedded embedded) {
|
||||
super(links, embedded);
|
||||
}
|
||||
|
||||
@@ -28,9 +28,11 @@ 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
|
||||
@@ -40,8 +42,9 @@ public class Signature implements Serializable {
|
||||
|
||||
private final String keyId;
|
||||
private final String type;
|
||||
private final boolean verified;
|
||||
private final SignatureStatus status;
|
||||
private final String owner;
|
||||
private final Set<String> contacts;
|
||||
|
||||
public Optional<String> getOwner() {
|
||||
return Optional.ofNullable(owner);
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
/*
|
||||
* 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;
|
||||
|
||||
public enum SignatureStatus {
|
||||
VERIFIED, NOT_FOUND, INVALID;
|
||||
}
|
||||
@@ -55,6 +55,7 @@ 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.PublicKeyDeletedEvent;
|
||||
import sonia.scm.security.ScmSecurityException;
|
||||
|
||||
import java.util.Set;
|
||||
@@ -100,14 +101,12 @@ import static sonia.scm.NotFoundException.notFound;
|
||||
* </code></pre>
|
||||
*
|
||||
* @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 +121,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 +134,7 @@ public final class RepositoryServiceFactory
|
||||
public RepositoryServiceFactory(ScmConfiguration configuration,
|
||||
CacheManager cacheManager, RepositoryManager repositoryManager,
|
||||
Set<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
|
||||
Set<ScmProtocolProvider> protocolProviders, WorkdirProvider workdirProvider)
|
||||
{
|
||||
Set<ScmProtocolProvider> protocolProviders, WorkdirProvider workdirProvider) {
|
||||
this(
|
||||
configuration, cacheManager, repositoryManager, resolvers,
|
||||
preProcessorUtil, protocolProviders, workdirProvider, ScmEventBus.getInstance()
|
||||
@@ -146,11 +143,10 @@ public final class RepositoryServiceFactory
|
||||
|
||||
@VisibleForTesting
|
||||
RepositoryServiceFactory(ScmConfiguration configuration,
|
||||
CacheManager cacheManager, RepositoryManager repositoryManager,
|
||||
Set<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
|
||||
Set<ScmProtocolProvider> protocolProviders, WorkdirProvider workdirProvider,
|
||||
ScmEventBus eventBus)
|
||||
{
|
||||
CacheManager cacheManager, RepositoryManager repositoryManager,
|
||||
Set<RepositoryServiceResolver> resolvers, PreProcessorUtil preProcessorUtil,
|
||||
Set<ScmProtocolProvider> protocolProviders, WorkdirProvider workdirProvider,
|
||||
ScmEventBus eventBus) {
|
||||
this.configuration = configuration;
|
||||
this.cacheManager = cacheManager;
|
||||
this.repositoryManager = repositoryManager;
|
||||
@@ -167,19 +163,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 +180,7 @@ public final class RepositoryServiceFactory
|
||||
|
||||
Repository repository = repositoryManager.get(repositoryId);
|
||||
|
||||
if (repository == null)
|
||||
{
|
||||
if (repository == null) {
|
||||
throw new NotFoundException(Repository.class, repositoryId);
|
||||
}
|
||||
|
||||
@@ -198,29 +190,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 +217,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 +234,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 +251,7 @@ public final class RepositoryServiceFactory
|
||||
}
|
||||
}
|
||||
|
||||
if (service == null)
|
||||
{
|
||||
if (service == null) {
|
||||
throw new RepositoryServiceNotFoundException(repository);
|
||||
}
|
||||
|
||||
@@ -284,8 +263,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<Cache<?, ?>> caches = Sets.newHashSet();
|
||||
private final CacheManager cacheManager;
|
||||
@@ -296,8 +274,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 +301,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 +317,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,11 +330,14 @@ public final class RepositoryServiceFactory
|
||||
cacheManager.getCache(BranchesCommandBuilder.CACHE_NAME).removeAll(predicate);
|
||||
}
|
||||
|
||||
@Subscribe
|
||||
public void onEvent(PublicKeyDeletedEvent 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);
|
||||
}
|
||||
|
||||
@@ -375,19 +351,29 @@ public final class RepositoryServiceFactory
|
||||
|
||||
//~--- 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<RepositoryServiceResolver> resolvers;
|
||||
|
||||
private Set<ScmProtocolProvider> protocolProviders;
|
||||
|
||||
@@ -27,6 +27,7 @@ package sonia.scm.security;
|
||||
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.
|
||||
@@ -49,9 +50,17 @@ public interface PublicKey {
|
||||
*/
|
||||
Optional<String> getOwner();
|
||||
|
||||
/**
|
||||
* Returns the contacts of the publickey.
|
||||
*
|
||||
* @return owner or empty optional
|
||||
*/
|
||||
Set<String> getContacts();
|
||||
|
||||
/**
|
||||
* Verifies that the signature is valid for the given data.
|
||||
* @param stream stream of data to verify
|
||||
*
|
||||
* @param stream stream of data to verify
|
||||
* @param signature signature
|
||||
* @return {@code true} if the signature is valid for the given data
|
||||
*/
|
||||
@@ -59,7 +68,8 @@ public interface PublicKey {
|
||||
|
||||
/**
|
||||
* Verifies that the signature is valid for the given data.
|
||||
* @param data data to verify
|
||||
*
|
||||
* @param data data to verify
|
||||
* @param signature signature
|
||||
* @return {@code true} if the signature is valid for the given data
|
||||
*/
|
||||
|
||||
@@ -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;
|
||||
|
||||
import sonia.scm.event.Event;
|
||||
|
||||
/**
|
||||
* This event is fired when a public key was removed from SCM-Manager.
|
||||
* @since 2.4.0
|
||||
*/
|
||||
@Event
|
||||
public class PublicKeyDeletedEvent {
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-git-plugin",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.ts",
|
||||
"scripts": {
|
||||
@@ -20,6 +20,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.3.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -51,7 +52,6 @@ import java.util.Optional;
|
||||
//~--- JDK imports ------------------------------------------------------------
|
||||
|
||||
/**
|
||||
*
|
||||
* @author Sebastian Sdorra
|
||||
*/
|
||||
public class GitChangesetConverter implements Closeable {
|
||||
@@ -137,11 +137,15 @@ public class GitChangesetConverter implements Closeable {
|
||||
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<PublicKey> publicKeyById = gpg.findPublicKey(publicKeyId);
|
||||
if (!publicKeyById.isPresent()) {
|
||||
// key not found
|
||||
return new Signature(publicKeyId, "gpg", false, null);
|
||||
return new Signature(publicKeyId, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet());
|
||||
}
|
||||
|
||||
PublicKey publicKey = publicKeyById.get();
|
||||
@@ -159,7 +163,13 @@ public class GitChangesetConverter implements Closeable {
|
||||
}
|
||||
|
||||
boolean verified = publicKey.verify(baos.toByteArray(), signature);
|
||||
return new Signature(publicKeyId, "gpg", verified, publicKey.getOwner().orElse(null));
|
||||
return new Signature(
|
||||
publicKeyId,
|
||||
"gpg",
|
||||
verified ? SignatureStatus.VERIFIED : SignatureStatus.INVALID,
|
||||
publicKey.getOwner().orElse(null),
|
||||
publicKey.getContacts()
|
||||
);
|
||||
}
|
||||
|
||||
public Person createPersonFor(PersonIdent personIndent) {
|
||||
|
||||
@@ -45,7 +45,6 @@ import org.eclipse.jgit.lib.GpgSignature;
|
||||
import org.eclipse.jgit.lib.GpgSigner;
|
||||
import org.eclipse.jgit.lib.PersonIdent;
|
||||
import org.eclipse.jgit.revwalk.RevCommit;
|
||||
import org.eclipse.jgit.revwalk.RevWalk;
|
||||
import org.eclipse.jgit.transport.CredentialsProvider;
|
||||
import org.junit.jupiter.api.AfterAll;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
@@ -72,6 +71,7 @@ import java.security.KeyPairGenerator;
|
||||
import java.security.NoSuchAlgorithmException;
|
||||
import java.security.NoSuchProviderException;
|
||||
import java.security.Security;
|
||||
import java.util.Collections;
|
||||
import java.util.Date;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -165,7 +165,7 @@ class GitChangesetConverterTest {
|
||||
when(gpg.findPublicKeyId(any())).thenReturn(identity);
|
||||
|
||||
Signature signature = addSignedCommitAndReturnSignature(identity);
|
||||
assertThat(signature).isEqualTo(new Signature(identity, "gpg", false, null));
|
||||
assertThat(signature).isEqualTo(new Signature(identity, "gpg", SignatureStatus.NOT_FOUND, null, Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -175,7 +175,7 @@ class GitChangesetConverterTest {
|
||||
setPublicKey(identity, owner, false);
|
||||
|
||||
Signature signature = addSignedCommitAndReturnSignature(identity);
|
||||
assertThat(signature).isEqualTo(new Signature(identity, "gpg", false, owner));
|
||||
assertThat(signature).isEqualTo(new Signature(identity, "gpg", SignatureStatus.INVALID, owner, Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -185,7 +185,7 @@ class GitChangesetConverterTest {
|
||||
setPublicKey(identity, owner, true);
|
||||
|
||||
Signature signature = addSignedCommitAndReturnSignature(identity);
|
||||
assertThat(signature).isEqualTo(new Signature(identity, "gpg", true, owner));
|
||||
assertThat(signature).isEqualTo(new Signature(identity, "gpg", SignatureStatus.VERIFIED, owner, Collections.emptySet()));
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -241,6 +241,7 @@ class GitChangesetConverterTest {
|
||||
|
||||
|
||||
}
|
||||
|
||||
private PGPKeyPair createKeyPair() throws PGPException, NoSuchProviderException, NoSuchAlgorithmException {
|
||||
KeyPairGenerator keyPairGenerator = KeyPairGenerator.getInstance("RSA", "BC");
|
||||
// we use a small key size to speedup test, a much larger size should be used for production
|
||||
|
||||
@@ -30,6 +30,7 @@ import sonia.scm.security.PublicKey;
|
||||
|
||||
import java.util.Collections;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public final class GitTestHelper {
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-hg-plugin",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.ts",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.3.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-legacy-plugin",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.tsx",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.3.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "@scm-manager/scm-svn-plugin",
|
||||
"private": true,
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"license": "MIT",
|
||||
"main": "./src/main/js/index.ts",
|
||||
"scripts": {
|
||||
@@ -19,6 +19,6 @@
|
||||
},
|
||||
"prettier": "@scm-manager/prettier-config",
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-plugins": "^2.3.0"
|
||||
"@scm-manager/ui-plugins": "^2.4.0-SNAPSHOT"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-components",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"description": "UI Components for SCM-Manager and its plugins",
|
||||
"main": "src/index.ts",
|
||||
"files": [
|
||||
@@ -47,7 +47,7 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-extensions": "^2.1.0",
|
||||
"@scm-manager/ui-types": "^2.3.0",
|
||||
"@scm-manager/ui-types": "^2.4.0-SNAPSHOT",
|
||||
"classnames": "^2.2.6",
|
||||
"date-fns": "^2.4.1",
|
||||
"gitdiff-parser": "^0.1.2",
|
||||
|
||||
@@ -1765,58 +1765,62 @@ exports[`Storyshots Changesets Co-Authors with avatar 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<span
|
||||
className="ChangesetAuthor__AvatarList-sc-1oz0xgw-0 bpcCrU"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:ford.prefect@hitchhiker.com"
|
||||
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
<img
|
||||
alt="Ford Prefect"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/ford.prefect@hitchhiker.com"
|
||||
/>
|
||||
SCM Administrator
|
||||
</a>
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<span
|
||||
className="ChangesetAuthor__AvatarList-sc-1oz0xgw-0 bpcCrU"
|
||||
>
|
||||
<img
|
||||
alt="Zaphod Beeblebrox"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:trillian@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto trillian@hitchhiker.cm"
|
||||
>
|
||||
<img
|
||||
alt="Tricia Marie McMillan"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/trillian@hitchhiker.cm"
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
</p>
|
||||
<a
|
||||
href="mailto:ford.prefect@hitchhiker.com"
|
||||
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
|
||||
>
|
||||
<img
|
||||
alt="Ford Prefect"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/ford.prefect@hitchhiker.com"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
<img
|
||||
alt="Zaphod Beeblebrox"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
|
||||
/>
|
||||
</a>
|
||||
<a
|
||||
href="mailto:trillian@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto trillian@hitchhiker.cm"
|
||||
>
|
||||
<img
|
||||
alt="Tricia Marie McMillan"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/trillian@hitchhiker.cm"
|
||||
/>
|
||||
</a>
|
||||
</span>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1931,47 +1935,51 @@ exports[`Storyshots Changesets Commiter and Co-Authors with avatar 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
,
|
||||
changeset.contributors.committedBy
|
||||
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
<img
|
||||
alt="Zaphod Beeblebrox"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
|
||||
/>
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<a
|
||||
href="mailto:ford.prefect@hitchhiker.com"
|
||||
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
|
||||
>
|
||||
<img
|
||||
alt="Ford Prefect"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/ford.prefect@hitchhiker.com"
|
||||
/>
|
||||
</a>
|
||||
|
||||
</p>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
,
|
||||
changeset.contributors.committedBy
|
||||
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
<img
|
||||
alt="Zaphod Beeblebrox"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/zaphod.beeblebrox@hitchhiker.cm"
|
||||
/>
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<a
|
||||
href="mailto:ford.prefect@hitchhiker.com"
|
||||
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
|
||||
>
|
||||
<img
|
||||
alt="Ford Prefect"
|
||||
className="ContributorAvatar-sc-1yz8zn-0 laExrp"
|
||||
src="https://robohash.org/ford.prefect@hitchhiker.com"
|
||||
/>
|
||||
</a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2073,18 +2081,22 @@ exports[`Storyshots Changesets Default 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
</p>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2196,18 +2208,22 @@ exports[`Storyshots Changesets Replacements 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
</p>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2309,30 +2325,34 @@ exports[`Storyshots Changesets With Committer 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.committedBy
|
||||
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
Zaphod Beeblebrox
|
||||
</a>
|
||||
|
||||
</p>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.committedBy
|
||||
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
Zaphod Beeblebrox
|
||||
</a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2434,39 +2454,43 @@ exports[`Storyshots Changesets With Committer and Co-Author 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
,
|
||||
changeset.contributors.committedBy
|
||||
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
Zaphod Beeblebrox
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<a
|
||||
href="mailto:ford.prefect@hitchhiker.com"
|
||||
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
|
||||
>
|
||||
Ford Prefect
|
||||
</a>
|
||||
|
||||
</p>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
,
|
||||
changeset.contributors.committedBy
|
||||
|
||||
<a
|
||||
href="mailto:zaphod.beeblebrox@hitchhiker.cm"
|
||||
title="changeset.contributors.mailto zaphod.beeblebrox@hitchhiker.cm"
|
||||
>
|
||||
Zaphod Beeblebrox
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<a
|
||||
href="mailto:ford.prefect@hitchhiker.com"
|
||||
title="changeset.contributors.mailto ford.prefect@hitchhiker.com"
|
||||
>
|
||||
Ford Prefect
|
||||
</a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2581,18 +2605,22 @@ exports[`Storyshots Changesets With avatar 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
</p>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -2694,31 +2722,35 @@ exports[`Storyshots Changesets With multiple Co-Authors 1`] = `
|
||||
<p
|
||||
className="is-hidden-desktop"
|
||||
/>
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
<div
|
||||
className="ChangesetRow__FlexRow-tkpti5-7 fTLhSo"
|
||||
>
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
<p
|
||||
className="ChangesetRow__AuthorWrapper-tkpti5-4 kDAubY is-size-7 is-ellipsis-overflow"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<a
|
||||
title="- Ford Prefect
|
||||
changeset.contributors.authoredBy
|
||||
|
||||
<a
|
||||
href="mailto:scm-admin@scm-manager.org"
|
||||
title="changeset.contributors.mailto scm-admin@scm-manager.org"
|
||||
>
|
||||
SCM Administrator
|
||||
</a>
|
||||
|
||||
commaSeparatedList.lastDivider
|
||||
|
||||
changeset.contributors.coAuthoredBy
|
||||
|
||||
<a
|
||||
title="- Ford Prefect
|
||||
- Zaphod Beeblebrox
|
||||
- Tricia Marie McMillan"
|
||||
>
|
||||
changeset.contributors.more
|
||||
</a>
|
||||
|
||||
</p>
|
||||
>
|
||||
changeset.contributors.more
|
||||
</a>
|
||||
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -35,6 +35,8 @@ import ChangesetAuthor from "./ChangesetAuthor";
|
||||
import ChangesetTags from "./ChangesetTags";
|
||||
import ChangesetButtonGroup from "./ChangesetButtonGroup";
|
||||
import ChangesetDescription from "./ChangesetDescription";
|
||||
import SignatureIcon from "@scm-manager/ui-webapp/src/repos/components/changesets/SignatureIcon";
|
||||
import { Level } from "../..";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
repository: Repository;
|
||||
@@ -79,6 +81,11 @@ const VCenteredChildColumn = styled.div`
|
||||
justify-content: flex-end;
|
||||
`;
|
||||
|
||||
const FlexRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
class ChangesetRow extends React.Component<Props> {
|
||||
createChangesetId = (changeset: Changeset) => {
|
||||
const { repository } = this.props;
|
||||
@@ -101,7 +108,7 @@ class ChangesetRow extends React.Component<Props> {
|
||||
<AvatarWrapper>
|
||||
<AvatarFigure className="media-left">
|
||||
<FixedSizedAvatar className="image">
|
||||
<AvatarImage person={changeset.author}/>
|
||||
<AvatarImage person={changeset.author} />
|
||||
</FixedSizedAvatar>
|
||||
</AvatarFigure>
|
||||
</AvatarWrapper>
|
||||
@@ -119,24 +126,32 @@ class ChangesetRow extends React.Component<Props> {
|
||||
</ExtensionPoint>
|
||||
</h4>
|
||||
<p className="is-hidden-touch">
|
||||
<Trans i18nKey="repos:changeset.summary" components={[changesetId, dateFromNow]}/>
|
||||
<Trans i18nKey="repos:changeset.summary" components={[changesetId, dateFromNow]} />
|
||||
</p>
|
||||
<p className="is-hidden-desktop">
|
||||
<Trans i18nKey="repos:changeset.shortSummary" components={[changesetId, dateFromNow]}/>
|
||||
<Trans i18nKey="repos:changeset.shortSummary" components={[changesetId, dateFromNow]} />
|
||||
</p>
|
||||
<AuthorWrapper className="is-size-7 is-ellipsis-overflow">
|
||||
<ChangesetAuthor changeset={changeset}/>
|
||||
</AuthorWrapper>
|
||||
<FlexRow>
|
||||
<AuthorWrapper className="is-size-7 is-ellipsis-overflow">
|
||||
<ChangesetAuthor changeset={changeset} />
|
||||
</AuthorWrapper>
|
||||
{changeset?.signatures && changeset.signatures.length > 0 && (
|
||||
<SignatureIcon
|
||||
className="mx-2 pt-1"
|
||||
signatures={changeset.signatures}
|
||||
/>
|
||||
)}
|
||||
</FlexRow>
|
||||
</Metadata>
|
||||
</div>
|
||||
</div>
|
||||
<VCenteredColumn className="column">
|
||||
<ChangesetTags changeset={changeset}/>
|
||||
<ChangesetTags changeset={changeset} />
|
||||
</VCenteredColumn>
|
||||
</div>
|
||||
</div>
|
||||
<VCenteredChildColumn className={classNames("column", "is-flex")}>
|
||||
<ChangesetButtonGroup repository={repository} changeset={changeset}/>
|
||||
<ChangesetButtonGroup repository={repository} changeset={changeset} />
|
||||
<ExtensionPoint
|
||||
name="changeset.right"
|
||||
props={{
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-plugins",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"ui-plugins": "./bin/ui-plugins.js"
|
||||
},
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-components": "^2.3.0",
|
||||
"@scm-manager/ui-components": "^2.4.0-SNAPSHOT",
|
||||
"@scm-manager/ui-extensions": "^2.1.0",
|
||||
"classnames": "^2.2.6",
|
||||
"query-string": "^5.0.1",
|
||||
@@ -25,7 +25,7 @@
|
||||
"@scm-manager/tsconfig": "^2.1.0",
|
||||
"@scm-manager/ui-scripts": "^2.1.0",
|
||||
"@scm-manager/ui-tests": "^2.1.0",
|
||||
"@scm-manager/ui-types": "^2.3.0",
|
||||
"@scm-manager/ui-types": "^2.4.0-SNAPSHOT",
|
||||
"@types/classnames": "^2.2.9",
|
||||
"@types/enzyme": "^3.10.3",
|
||||
"@types/fetch-mock": "^7.3.1",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-styles",
|
||||
"version": "2.1.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"description": "Styles for SCM-Manager",
|
||||
"main": "src/scm.scss",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-types",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"description": "Flow types for SCM-Manager related Objects",
|
||||
"main": "src/index.ts",
|
||||
"files": [
|
||||
|
||||
@@ -33,6 +33,7 @@ export type Changeset = Collection & {
|
||||
author: Person;
|
||||
description: string;
|
||||
contributors?: Contributor[];
|
||||
signatures?: Signature[];
|
||||
_links: Links;
|
||||
_embedded: {
|
||||
tags?: Tag[];
|
||||
@@ -41,6 +42,14 @@ export type Changeset = Collection & {
|
||||
};
|
||||
};
|
||||
|
||||
export type Signature = {
|
||||
keyId: string;
|
||||
type: string;
|
||||
status: "VERIFIED" | "NOT_FOUND" | "INVALID";
|
||||
owner: string;
|
||||
contacts: string[];
|
||||
}
|
||||
|
||||
export type Contributor = {
|
||||
person: Person;
|
||||
type: string;
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"name": "@scm-manager/ui-webapp",
|
||||
"version": "2.3.0",
|
||||
"version": "2.4.0-SNAPSHOT",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@scm-manager/ui-components": "^2.3.0",
|
||||
"@scm-manager/ui-components": "^2.4.0-SNAPSHOT",
|
||||
"@scm-manager/ui-extensions": "^2.1.0",
|
||||
"classnames": "^2.2.5",
|
||||
"history": "^4.10.1",
|
||||
|
||||
@@ -88,6 +88,12 @@
|
||||
"shortSummary": "Committet <0/> <1/>",
|
||||
"tags": "Tags",
|
||||
"diffNotSupported": "Diff des Changesets wird von diesem Repositorytyp nicht unterstützt",
|
||||
"signedBy": "Signiert von",
|
||||
"signatureStatus": "Status",
|
||||
"keyId": "Schlüssel-ID",
|
||||
"signatureVerified": "Verifiziert",
|
||||
"signatureNotVerified": "Nicht verifiziert",
|
||||
"signatureInvalid": "Ungültig",
|
||||
"shortlink": {
|
||||
"title": "Changeset {{id}} aus {{namespace}}/{{name}}"
|
||||
},
|
||||
|
||||
@@ -87,6 +87,12 @@
|
||||
"summary": "Changeset <0/> was committed <1/>",
|
||||
"shortSummary": "Committed <0/> <1/>",
|
||||
"tags": "Tags",
|
||||
"signedBy": "Signed by",
|
||||
"keyId": "Key ID",
|
||||
"signatureStatus": "Status",
|
||||
"signatureVerified": "verified",
|
||||
"signatureNotVerified": "not verified",
|
||||
"signatureInvalid": "invalid",
|
||||
"shortlink": {
|
||||
"title": "Changeset {{id}} of {{namespace}}/{{name}}"
|
||||
},
|
||||
|
||||
@@ -26,7 +26,7 @@ import { Trans, useTranslation, WithTranslation, withTranslation } from "react-i
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
import { Changeset, Repository, Tag, ParentChangeset } from "@scm-manager/ui-types";
|
||||
import { Changeset, ParentChangeset, Repository, Tag } from "@scm-manager/ui-types";
|
||||
import {
|
||||
AvatarImage,
|
||||
AvatarWrapper,
|
||||
@@ -38,11 +38,12 @@ import {
|
||||
changesets,
|
||||
ChangesetTag,
|
||||
DateFromNow,
|
||||
Level,
|
||||
Icon
|
||||
Icon,
|
||||
Level
|
||||
} from "@scm-manager/ui-components";
|
||||
import ContributorTable from "./ContributorTable";
|
||||
import { Link as ReactLink } from "react-router-dom";
|
||||
import SignatureIcon from "./SignatureIcon";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
changeset: Changeset;
|
||||
@@ -63,6 +64,10 @@ const TagsWrapper = styled.div`
|
||||
}
|
||||
`;
|
||||
|
||||
const SignedIcon = styled(SignatureIcon)`
|
||||
padding-left: 1rem;
|
||||
`;
|
||||
|
||||
const BottomMarginLevel = styled(Level)`
|
||||
margin-bottom: 1rem !important;
|
||||
`;
|
||||
@@ -74,6 +79,11 @@ const countContributors = (changeset: Changeset) => {
|
||||
return 1;
|
||||
};
|
||||
|
||||
const FlexRow = styled.div`
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
`;
|
||||
|
||||
const ContributorLine = styled.div`
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
@@ -120,12 +130,19 @@ const SeparatedParents = styled.div`
|
||||
const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
const [open, setOpen] = useState(false);
|
||||
const signatureIcon = changeset?.signatures && changeset.signatures.length > 0 && (
|
||||
<SignatureIcon className="mx-2" signatures={changeset.signatures} />
|
||||
);
|
||||
|
||||
if (open) {
|
||||
return (
|
||||
<ContributorDetails>
|
||||
<ContributorToggleLine onClick={e => setOpen(!open)}>
|
||||
<Icon name="angle-down" /> {t("changeset.contributors.list")}
|
||||
</ContributorToggleLine>
|
||||
<FlexRow>
|
||||
<ContributorToggleLine onClick={e => setOpen(!open)} className="is-ellipsis-overflow">
|
||||
<Icon name="angle-down" /> {t("changeset.contributors.list")}
|
||||
</ContributorToggleLine>
|
||||
{signatureIcon}
|
||||
</FlexRow>
|
||||
<ContributorTable changeset={changeset} />
|
||||
</ContributorDetails>
|
||||
);
|
||||
@@ -133,9 +150,10 @@ const Contributors: FC<{ changeset: Changeset }> = ({ changeset }) => {
|
||||
return (
|
||||
<>
|
||||
<ContributorLine onClick={e => setOpen(!open)}>
|
||||
<ContributorColumn>
|
||||
<ContributorColumn className="is-ellipsis-overflow">
|
||||
<Icon name="angle-right" /> <ChangesetAuthor changeset={changeset} />
|
||||
</ContributorColumn>
|
||||
{signatureIcon}
|
||||
<CountColumn className={"is-hidden-mobile"}>
|
||||
(
|
||||
<span className="has-text-link">
|
||||
@@ -190,9 +208,9 @@ class ChangesetDetails extends React.Component<Props, State> {
|
||||
<AvatarImage person={changeset.author} />
|
||||
</RightMarginP>
|
||||
</AvatarWrapper>
|
||||
<div className="media-content is-ellipsis-overflow">
|
||||
<div className="media-content">
|
||||
<Contributors changeset={changeset} />
|
||||
<ChangesetSummary>
|
||||
<ChangesetSummary className="is-ellipsis-overflow">
|
||||
<p>
|
||||
<Trans i18nKey="repos:changeset.summary" components={[id, date]} />
|
||||
</p>
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
/*
|
||||
* 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 { Icon, Tooltip } from "@scm-manager/ui-components";
|
||||
import { useTranslation } from "react-i18next";
|
||||
//import { Signature } from "@scm-manager/ui-types";
|
||||
|
||||
type Props = {
|
||||
signatures: any[];
|
||||
className: any;
|
||||
};
|
||||
|
||||
const SignatureIcon: FC<Props> = ({ signatures, className }) => {
|
||||
const [t] = useTranslation("repos");
|
||||
|
||||
const signature = signatures?.length > 0 ? signatures[0] : undefined;
|
||||
|
||||
const createTooltipMessage = () => {
|
||||
let status;
|
||||
if (signature.status === "VERIFIED") {
|
||||
status = t("changeset.signatureVerified");
|
||||
} else if (signature.status === "INVALID") {
|
||||
status = t("changeset.signatureInvalid");
|
||||
} else {
|
||||
status = t("changeset.signatureNotVerified");
|
||||
}
|
||||
|
||||
if (signature.status === "NOT_FOUND") {
|
||||
return `${t("changeset.signatureStatus")}: ${status}`;
|
||||
}
|
||||
|
||||
return `${t("changeset.signedBy")}: ${signature.owner}\n${t("changeset.keyId")}: ${signature.keyId}\n${t(
|
||||
"changeset.signatureStatus"
|
||||
)}: ${status}\n${t("changeset.keyKontacts")}: ${signature.contacts.map((contact: string) => `\n- ${contact}`)}`;
|
||||
};
|
||||
|
||||
const getColor = () => {
|
||||
if (signature.status === "VERIFIED") {
|
||||
return "success";
|
||||
}
|
||||
if (signature.status === "INVALID") {
|
||||
return "danger";
|
||||
}
|
||||
return undefined;
|
||||
};
|
||||
|
||||
if (!signature) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Tooltip location="top" message={createTooltipMessage()}>
|
||||
<Icon name={"key"} className={className} color={getColor()} />
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default SignatureIcon;
|
||||
@@ -24,7 +24,10 @@
|
||||
|
||||
package sonia.scm.security.gpg;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.bcpg.ArmoredInputStream;
|
||||
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;
|
||||
@@ -32,6 +35,7 @@ import sonia.scm.security.PrivateKey;
|
||||
import sonia.scm.security.PublicKey;
|
||||
|
||||
import javax.inject.Inject;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -51,8 +55,11 @@ public class DefaultGPG implements GPG {
|
||||
@Override
|
||||
public String findPublicKeyId(byte[] signature) {
|
||||
try {
|
||||
return Keys.resolveIdFromKey(new String(signature));
|
||||
} catch (PGPException | IOException e) {
|
||||
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 "";
|
||||
@@ -61,7 +68,8 @@ public class DefaultGPG implements GPG {
|
||||
@Override
|
||||
public Optional<PublicKey> findPublicKey(String id) {
|
||||
Optional<RawGpgKey> key = store.findById(id);
|
||||
return key.map(rawGpgKey -> new GpgKey(id, rawGpgKey.getOwner()));
|
||||
|
||||
return key.map(rawGpgKey -> new GpgKey(id, rawGpgKey.getOwner(), rawGpgKey.getRaw().getBytes()));
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -71,7 +79,7 @@ public class DefaultGPG implements GPG {
|
||||
if (!keys.isEmpty()) {
|
||||
return keys
|
||||
.stream()
|
||||
.map(rawGpgKey -> new GpgKey(rawGpgKey.getId(), rawGpgKey.getOwner()))
|
||||
.map(rawGpgKey -> new GpgKey(rawGpgKey.getId(), rawGpgKey.getOwner(), rawGpgKey.getRaw().getBytes()))
|
||||
.collect(Collectors.toSet());
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,9 @@ import java.io.IOException;
|
||||
import java.io.InputStream;
|
||||
import java.io.InputStreamReader;
|
||||
import java.nio.charset.StandardCharsets;
|
||||
import java.util.LinkedHashSet;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
public class GpgKey implements PublicKey {
|
||||
|
||||
@@ -51,10 +53,16 @@ public class GpgKey implements PublicKey {
|
||||
|
||||
private final String id;
|
||||
private final String owner;
|
||||
private final Set<String> contacts = new LinkedHashSet<>();
|
||||
|
||||
public GpgKey(String id, String owner) {
|
||||
public GpgKey(String id, String owner, byte[] raw) {
|
||||
this.id = id;
|
||||
this.owner = owner;
|
||||
try {
|
||||
getPgpPublicKey(raw).getUserIDs().forEachRemaining(contacts::add);
|
||||
} catch (IOException e) {
|
||||
LOG.error("Could not find contacts in public key", e);
|
||||
}
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -70,13 +78,16 @@ public class GpgKey implements PublicKey {
|
||||
return Optional.of(owner);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<String> getContacts() {
|
||||
return contacts;
|
||||
}
|
||||
|
||||
@Override
|
||||
public boolean verify(InputStream stream, byte[] signature) {
|
||||
boolean verified = false;
|
||||
try {
|
||||
ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature));
|
||||
PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
|
||||
PGPPublicKey publicKey = ((PGPPublicKeyRing) pgpObjectFactory.nextObject()).getPublicKey();
|
||||
PGPPublicKey publicKey = getPgpPublicKey(signature);
|
||||
PGPSignature pgpSignature = ((PGPSignature) publicKey.getSignatures().next());
|
||||
|
||||
PGPContentVerifierBuilderProvider provider = new JcaPGPContentVerifierBuilderProvider();
|
||||
@@ -97,6 +108,12 @@ public class GpgKey implements PublicKey {
|
||||
}
|
||||
return verified;
|
||||
}
|
||||
|
||||
private PGPPublicKey getPgpPublicKey(byte[] signature) throws IOException {
|
||||
ArmoredInputStream armoredInputStream = new ArmoredInputStream(new ByteArrayInputStream(signature));
|
||||
PGPObjectFactory pgpObjectFactory = new PGPObjectFactory(armoredInputStream, new JcaKeyFingerprintCalculator());
|
||||
return ((PGPPublicKeyRing) pgpObjectFactory.nextObject()).getPublicKey();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
package sonia.scm.security.gpg;
|
||||
|
||||
import lombok.Value;
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import org.bouncycastle.openpgp.PGPPublicKey;
|
||||
import org.bouncycastle.openpgp.PGPPublicKeyRing;
|
||||
@@ -37,45 +38,71 @@ 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.stream.Collectors;
|
||||
import java.util.Set;
|
||||
import java.util.function.Function;
|
||||
|
||||
@Value
|
||||
final class Keys {
|
||||
|
||||
private static final KeyFingerPrintCalculator calculator = new JcaKeyFingerprintCalculator();
|
||||
|
||||
private Keys() {}
|
||||
private final String master;
|
||||
private final Set<String> subs;
|
||||
|
||||
static String resolveIdFromKey(String rawKey) throws IOException, PGPException {
|
||||
List<PGPPublicKey> keys = collectKeys(rawKey);
|
||||
if (keys.size() > 1) {
|
||||
keys = keys.stream().filter(PGPPublicKey::isMasterKey).collect(Collectors.toList());
|
||||
}
|
||||
if (keys.isEmpty()) {
|
||||
throw new IllegalArgumentException("found multiple keys, but no master keys");
|
||||
}
|
||||
if (keys.size() > 1) {
|
||||
throw new IllegalArgumentException("found multiple master keys");
|
||||
private Keys(String master, Set<String> subs) {
|
||||
this.master = master;
|
||||
this.subs = subs;
|
||||
}
|
||||
|
||||
static Keys resolve(String raw) {
|
||||
return resolve(raw, Keys::collectKeys);
|
||||
}
|
||||
|
||||
static Keys resolve(String raw, Function<String, List<PGPPublicKey>> parser) {
|
||||
List<PGPPublicKey> parsedKeys = parser.apply(raw);
|
||||
|
||||
String master = null;
|
||||
Set<String> 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));
|
||||
}
|
||||
}
|
||||
|
||||
PGPPublicKey pgpPublicKey = keys.get(0);
|
||||
return createId(pgpPublicKey);
|
||||
if (master == null) {
|
||||
throw new IllegalArgumentException("No master key found");
|
||||
}
|
||||
|
||||
return new Keys(master, Collections.unmodifiableSet(subs));
|
||||
}
|
||||
|
||||
private static String createId(PGPPublicKey pgpPublicKey) {
|
||||
return "0x" + Long.toHexString(pgpPublicKey.getKeyID()).toUpperCase(Locale.ENGLISH);
|
||||
}
|
||||
|
||||
private static List<PGPPublicKey> collectKeys(String rawKey) throws IOException, PGPException {
|
||||
List<PGPPublicKey> 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);
|
||||
private static List<PGPPublicKey> collectKeys(String rawKey) {
|
||||
try {
|
||||
List<PGPPublicKey> 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);
|
||||
}
|
||||
return publicKeys;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -24,16 +24,16 @@
|
||||
|
||||
package sonia.scm.security.gpg;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
import sonia.scm.ContextEntry;
|
||||
import sonia.scm.event.ScmEventBus;
|
||||
import sonia.scm.security.NotPublicKeyException;
|
||||
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.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
@@ -43,31 +43,39 @@ import java.util.stream.Collectors;
|
||||
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<RawGpgKey> store;
|
||||
private final DataStore<MasterKeyReference> subKeyStore;
|
||||
private final ScmEventBus eventBus;
|
||||
|
||||
@Inject
|
||||
public PublicKeyStore(DataStoreFactory dataStoreFactory) {
|
||||
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) {
|
||||
UserPermissions.modify(username).check();
|
||||
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");
|
||||
}
|
||||
|
||||
try {
|
||||
String id = Keys.resolveIdFromKey(rawKey);
|
||||
RawGpgKey key = new RawGpgKey(id, displayName, username, rawKey, Instant.now());
|
||||
Keys keys = Keys.resolve(rawKey);
|
||||
String master = keys.getMaster();
|
||||
|
||||
store.put(id, key);
|
||||
|
||||
return key;
|
||||
} catch (IOException | PGPException e) {
|
||||
throw new GPGException("failed to resolve id from gpg key");
|
||||
for (String subKey : keys.getSubs()) {
|
||||
subKeyStore.put(subKey, new MasterKeyReference(master));
|
||||
}
|
||||
|
||||
RawGpgKey key = new RawGpgKey(master, displayName, username, rawKey, Instant.now());
|
||||
|
||||
store.put(master, key);
|
||||
|
||||
return key;
|
||||
|
||||
}
|
||||
|
||||
public void delete(String id) {
|
||||
@@ -75,10 +83,17 @@ public class PublicKeyStore {
|
||||
if (rawGpgKey != null) {
|
||||
UserPermissions.modify(rawGpgKey.getOwner()).check();
|
||||
store.remove(id);
|
||||
eventBus.post(new PublicKeyDeletedEvent());
|
||||
}
|
||||
}
|
||||
|
||||
public Optional<RawGpgKey> findById(String id) {
|
||||
Optional<MasterKeyReference> reference = subKeyStore.getOptional(id);
|
||||
|
||||
if (reference.isPresent()) {
|
||||
return store.getOptional(reference.get().getMasterKey());
|
||||
}
|
||||
|
||||
return store.getOptional(id);
|
||||
}
|
||||
|
||||
|
||||
@@ -35,6 +35,7 @@ import sonia.scm.security.PublicKey;
|
||||
import java.io.IOException;
|
||||
import java.time.Instant;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.when;
|
||||
@@ -50,15 +51,16 @@ class DefaultGPGTest {
|
||||
|
||||
@Test
|
||||
void shouldFindIdInSignature() throws IOException {
|
||||
String raw = GPGTestHelper.readKey("single.asc");
|
||||
String raw = GPGTestHelper.readKey("signature.asc");
|
||||
String publicKeyId = gpg.findPublicKeyId(raw.getBytes());
|
||||
|
||||
assertThat(publicKeyId).isEqualTo("0x975922F193B07D6E");
|
||||
assertThat(publicKeyId).isEqualTo("0x1F17B79A09DAD5B9");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindPublicKey() {
|
||||
RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", "raw", Instant.now());
|
||||
void shouldFindPublicKey() throws IOException {
|
||||
String raw = GPGTestHelper.readKey("subkeys.asc");
|
||||
RawGpgKey key1 = new RawGpgKey("42", "key_42", "trillian", raw, Instant.now());
|
||||
|
||||
when(store.findById("42")).thenReturn(Optional.of(key1));
|
||||
|
||||
@@ -68,12 +70,17 @@ class DefaultGPGTest {
|
||||
assertThat(publicKey.get().getOwner()).isPresent();
|
||||
assertThat(publicKey.get().getOwner().get()).contains("trillian");
|
||||
assertThat(publicKey.get().getId()).isEqualTo("42");
|
||||
assertThat(publicKey.get().getContacts()).contains("Sebastian Sdorra <s.sdorra@gmail.com>",
|
||||
"Sebastian Sdorra <sebastian.sdorra@cloudogu.com>");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldFindKeysForUsername() {
|
||||
RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", "raw", Instant.now());
|
||||
RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", "raw", Instant.now());
|
||||
void shouldFindKeysForUsername() throws IOException {
|
||||
String raw = GPGTestHelper.readKey("single.asc");
|
||||
String raw2= GPGTestHelper.readKey("subkeys.asc");
|
||||
|
||||
RawGpgKey key1 = new RawGpgKey("1", "1", "trillian", raw, Instant.now());
|
||||
RawGpgKey key2 = new RawGpgKey("2", "2", "trillian", raw2, Instant.now());
|
||||
when(store.findByUsername("trillian")).thenReturn(ImmutableList.of(key1, key2));
|
||||
|
||||
Iterable<PublicKey> keys = gpg.findPublicKeysByUsername("trillian");
|
||||
|
||||
@@ -28,8 +28,6 @@ import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class GpgKeyTest {
|
||||
|
||||
@Test
|
||||
@@ -41,11 +39,11 @@ class GpgKeyTest {
|
||||
|
||||
byte[] raw = GPGTestHelper.readKey("subkeys.asc").getBytes();
|
||||
|
||||
GpgKey key = new GpgKey("1", "trillian");
|
||||
GpgKey key = new GpgKey("1", "trillian", raw);
|
||||
|
||||
boolean verified = key.verify(longContent.toString().getBytes(), raw);
|
||||
|
||||
// assertThat(verified).isTrue();
|
||||
// assertThat(verified).isTrue();
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -24,26 +24,58 @@
|
||||
|
||||
package sonia.scm.security.gpg;
|
||||
|
||||
import org.bouncycastle.openpgp.PGPException;
|
||||
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.readKey;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class KeysTest {
|
||||
|
||||
@Test
|
||||
void shouldResolveId() throws IOException, PGPException {
|
||||
void shouldResolveSingleId() throws IOException {
|
||||
String rawPublicKey = readKey("single.asc");
|
||||
assertThat(Keys.resolveIdFromKey(rawPublicKey)).isEqualTo("0x975922F193B07D6E");
|
||||
Keys keys = Keys.resolve(rawPublicKey);
|
||||
assertThat(keys.getMaster()).isEqualTo("0x975922F193B07D6E");
|
||||
}
|
||||
|
||||
@Test
|
||||
void shouldResolveIdFromMasterKey() throws IOException, PGPException {
|
||||
void shouldResolveIdsFromSubkeys() throws IOException {
|
||||
String rawPublicKey = readKey("subkeys.asc");
|
||||
assertThat(Keys.resolveIdFromKey(rawPublicKey)).isEqualTo("0x13B13D4C8A9350A1");
|
||||
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;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -33,7 +33,9 @@ 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.security.NotPublicKeyException;
|
||||
import sonia.scm.security.PublicKeyDeletedEvent;
|
||||
import sonia.scm.store.DataStoreFactory;
|
||||
import sonia.scm.store.InMemoryDataStoreFactory;
|
||||
|
||||
@@ -44,7 +46,9 @@ 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.verify;
|
||||
|
||||
@ExtendWith(MockitoExtension.class)
|
||||
class PublicKeyStoreTest {
|
||||
@@ -52,12 +56,15 @@ 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);
|
||||
keyStore = new PublicKeyStore(dataStoreFactory, eventBus);
|
||||
}
|
||||
|
||||
@BeforeEach
|
||||
@@ -72,7 +79,7 @@ class PublicKeyStoreTest {
|
||||
|
||||
@Test
|
||||
void shouldThrowAuthorizationExceptionOnAdd() throws IOException {
|
||||
doThrow(AuthorizationException.class).when(subject).checkPermission("user:modify:zaphod");
|
||||
doThrow(AuthorizationException.class).when(subject).checkPermission("user:changePublicKeys:zaphod");
|
||||
String rawKey = GPGTestHelper.readKey("single.asc");
|
||||
|
||||
assertThrows(AuthorizationException.class, () -> keyStore.add("zaphods key", "zaphod", rawKey));
|
||||
@@ -118,6 +125,8 @@ class PublicKeyStoreTest {
|
||||
key = keyStore.findById("0x975922F193B07D6E");
|
||||
|
||||
assertThat(key).isNotPresent();
|
||||
|
||||
verify(eventBus).post(any(PublicKeyDeletedEvent.class));
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
-----BEGIN PGP SIGNATURE-----
|
||||
|
||||
iQIzBAABCgAdFiEEAibrQDifYryAFPjQHxe3mgna1bkFAl8gSFMACgkQHxe3mgna
|
||||
1bk0Hg/9HaN7aRkRo1FH5xPnswGFAidHG+XQBFYTK3EhR2g6m4iRCij58qIEMQVh
|
||||
gV6FTtz6xGB/Oq32e5+gp9dYTp6lTrfm35hjg7uzwiC7OQx3QswZn7GUX/ZmuLk0
|
||||
nqz4ryiVxeMWst47JkKAm9PY6GC+UITaL3tptNF//MBPAwEfNnDP7O667BTvnROh
|
||||
9XaSlIdYlbGxs5YzQK8BMB56YdutTDMtHl92oOjDA7r238dScmlNUSw3IQIsNPkz
|
||||
4WZwGM7HVxw7PjbRoMbwXbNEJ87F4SuBqhWM7BjHEUFS21wH8APtVZrNzd2znveq
|
||||
oemn/+9pn0LG3Mg/2FASqHA6X+HS79YT9fb0O3HUvHzlaJmev88h4JSGcTJZG5+o
|
||||
a9LEPW56clJYmCq/ghKAyV+bJfUkIAP9i75p4zi8Il4ACJnf9oVRg6RuOTXK5cnf
|
||||
bvSzEtBWlXT1ELV52uo9gwcQMqgkQ89p8pTHcYHD3UhfdsuCfzlTGP/aQ/6OUBGg
|
||||
k6AFS1kAwalAp2VEOBIJUXylM60VfdLaLfWpgg4T8mq4WIV3ROXTV0o2XciuF4r0
|
||||
2oXtyc84J7nnGJlJ4HqHBqzMNHF4giqVNhKQzBAQ1OEjePBfkW6RMDcYxkLilUrU
|
||||
LzguRD1ybuS9xYQK60s2S68sNoAFkY8NTn9z8je1BO7ajzQn/fw=
|
||||
=smHD
|
||||
-----END PGP SIGNATURE-----
|
||||
25
yarn.lock
25
yarn.lock
@@ -984,6 +984,13 @@
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/runtime@^7.10.1":
|
||||
version "7.10.5"
|
||||
resolved "https://registry.yarnpkg.com/@babel/runtime/-/runtime-7.10.5.tgz#303d8bd440ecd5a491eae6117fd3367698674c5c"
|
||||
integrity sha512-otddXKhdNn7d0ptoFRHtMLa8LqDxLYwTjB4nYgM1yy5N6gU/MUf8zqyyLltCH3yAVitBzmwK4us+DD0l/MauAg==
|
||||
dependencies:
|
||||
regenerator-runtime "^0.13.4"
|
||||
|
||||
"@babel/template@^7.10.1", "@babel/template@^7.3.3":
|
||||
version "7.10.1"
|
||||
resolved "https://registry.yarnpkg.com/@babel/template/-/template-7.10.1.tgz#e167154a94cb5f14b28dc58f5356d2162f539811"
|
||||
@@ -4917,10 +4924,10 @@ bulma-tooltip@^3.0.0:
|
||||
resolved "https://registry.yarnpkg.com/bulma-tooltip/-/bulma-tooltip-3.0.2.tgz#2cf0abab1de2eba07f9d84eb7f07a8a88819ea92"
|
||||
integrity sha512-CsT3APjhlZScskFg38n8HYL8oYNUHQtcu4sz6ERarxkUpBRbk9v0h/5KAvXeKapVSn2dp9l7bOGit5SECP8EWQ==
|
||||
|
||||
bulma@^0.8.0:
|
||||
version "0.8.2"
|
||||
resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.8.2.tgz#5d928f16ed4a84549c2873f95c92c38c69c631a7"
|
||||
integrity sha512-vMM/ijYSxX+Sm+nD7Lmc1UgWDy2JcL2nTKqwgEqXuOMU+IGALbXd5MLt/BcjBAPLIx36TtzhzBcSnOP974gcqA==
|
||||
bulma@^0.9.0:
|
||||
version "0.9.0"
|
||||
resolved "https://registry.yarnpkg.com/bulma/-/bulma-0.9.0.tgz#948c5445a49e9d7546f0826cb3820d17178a814f"
|
||||
integrity sha512-rV75CJkubNUroAt0qCRkjznZLoaXq/ctfMXsMvKSL84UetbSyx5REl96e8GoQ04G4Tkw0XF3STECffTOQrbzOQ==
|
||||
|
||||
byline@^5.0.0:
|
||||
version "5.0.0"
|
||||
@@ -8656,12 +8663,12 @@ i18next@*:
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.1"
|
||||
|
||||
i18next@^17.3.0:
|
||||
version "17.3.1"
|
||||
resolved "https://registry.yarnpkg.com/i18next/-/i18next-17.3.1.tgz#5fe75e054aae39a6f38f1a79f7ab49184c6dc7a1"
|
||||
integrity sha512-4nY+yaENaoZKmpbiDXPzucVHCN3hN9Z9Zk7LyQXVOKVIpnYOJ3L/yxHJlBPtJDq3PGgjFwA0QBFm/26Z0iDT5A==
|
||||
i18next@^19.6.0:
|
||||
version "19.6.3"
|
||||
resolved "https://registry.yarnpkg.com/i18next/-/i18next-19.6.3.tgz#ce2346161b35c4c5ab691b0674119c7b349c0817"
|
||||
integrity sha512-eYr98kw/C5z6kY21ti745p4IvbOJwY8F2T9tf/Lvy5lFnYRqE45+bppSgMPmcZZqYNT+xO0N0x6rexVR2wtZZQ==
|
||||
dependencies:
|
||||
"@babel/runtime" "^7.3.1"
|
||||
"@babel/runtime" "^7.10.1"
|
||||
|
||||
iconv-lite@0.4.24, iconv-lite@^0.4.24, iconv-lite@~0.4.13:
|
||||
version "0.4.24"
|
||||
|
||||
Reference in New Issue
Block a user