Implement file lock for git (#1838)

Adds a "file lock" command that can be used to mark files as locked by a specific user. This command is implemented for git using a store to keep the locks.

Additionally, the Git LFS locking API is implemented.

To display locks, the scm-manager/scm-file-lock-plugin can be used.

Co-authored-by: Eduard Heimbuch <eduard.heimbuch@cloudogu.com>
This commit is contained in:
René Pfeuffer
2021-11-01 16:54:58 +01:00
committed by GitHub
parent 87aea1936b
commit e1a2d27256
44 changed files with 4970 additions and 787 deletions

View File

@@ -0,0 +1,2 @@
- type: added
descripion: File lock implementation for git (lfs) ([#1838](https://github.com/scm-manager/scm-manager/pull/1838))

View File

@@ -77,5 +77,10 @@ public enum Command
/** /**
* @since 2.19.0 * @since 2.19.0
*/ */
MIRROR; MIRROR,
/**
* @since 2.26.0
*/
FILE_LOCK
} }

View File

@@ -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.repository.api;
import java.io.Serializable;
import java.time.Instant;
/**
* Detailes of a file lock.
*
* @since 2.26.0
*/
public class FileLock implements Serializable {
private static final long serialVersionUID = 1902345795392347027L;
private final String path;
private final String id;
private final String userId;
private final Instant timestamp;
public FileLock(String path, String id, String userId, Instant timestamp) {
this.path = path;
this.id = id;
this.userId = userId;
this.timestamp = timestamp;
}
/**
* The path of the locked file.
*/
public String getPath() {
return path;
}
/**
* The id of the lock.
*/
public String getId() {
return id;
}
/**
* The id of the user that created the lock.
*/
public String getUserId() {
return userId;
}
/**
* The time the lock was created.
*/
public Instant getTimestamp() {
return timestamp;
}
}

View File

@@ -0,0 +1,160 @@
/*
* 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.api;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.spi.FileLockCommand;
import sonia.scm.repository.spi.LockCommandRequest;
import sonia.scm.repository.spi.LockStatusCommandRequest;
import sonia.scm.repository.spi.UnlockCommandRequest;
import java.util.Collection;
import java.util.Optional;
/**
* Can lock and unlock files and check lock states. Locked files can only be modified by the user holding the lock.
*
* @since 2.26.0
*/
public final class FileLockCommandBuilder {
private final FileLockCommand fileLockCommand;
private final Repository repository;
public FileLockCommandBuilder(FileLockCommand fileLockCommand, Repository repository) {
this.fileLockCommand = fileLockCommand;
this.repository = repository;
}
/**
* Creates builder to lock the given file.
*
* @param file The file to lock.
* @return Builder for lock creation.
*/
public InnerLockCommandBuilder lock(String file) {
RepositoryPermissions.push(repository).check();
return new InnerLockCommandBuilder(file);
}
/**
* Creates builder to unlock the given file.
*
* @param file The file to unlock.
* @return Builder to unlock a file.
*/
public InnerUnlockCommandBuilder unlock(String file) {
RepositoryPermissions.push(repository).check();
return new InnerUnlockCommandBuilder(file);
}
/**
* Retrieves the lock for a file, if it is locked.
*
* @param file The file to get the lock for.
* @return {@link Optional} with the lock, if the file is locked,
* or {@link Optional#empty()}, if the file is not locked
*/
public Optional<FileLock> status(String file) {
LockStatusCommandRequest lockStatusCommandRequest = new LockStatusCommandRequest();
lockStatusCommandRequest.setFile(file);
return fileLockCommand.status(lockStatusCommandRequest);
}
/**
* Retrieves all locks for the repository.
*
* @return Collection of all locks for the repository.
*/
public Collection<FileLock> getAll() {
return fileLockCommand.getAll();
}
public class InnerLockCommandBuilder {
private final String file;
public InnerLockCommandBuilder(String file) {
this.file = file;
}
/**
* Creates the lock.
*
* @return The result of the lock creation.
* @throws FileLockedException if the file is already locked.
*/
public LockCommandResult execute() {
LockCommandRequest lockCommandRequest = new LockCommandRequest();
lockCommandRequest.setFile(file);
return fileLockCommand.lock(lockCommandRequest);
}
}
public class InnerUnlockCommandBuilder {
private final String file;
private boolean force;
public InnerUnlockCommandBuilder(String file) {
this.file = file;
}
/**
* Set the command to force unlock. Shortcur for <code>force(true)</code>.
*
* @return This builder instance.
* @see #force(boolean)
*/
public InnerUnlockCommandBuilder force() {
return force(true);
}
/**
* Set whether to force unlock or not. A lock from a different user can only
* be removed with force set to <code>true</code>.
*
* @param force Whether to force unlock or not.
* @return This builder instance.
*/
public InnerUnlockCommandBuilder force(boolean force) {
this.force = force;
return this;
}
/**
* Remove the lock.
*
* @return The result of the lock removal.
* @throws FileLockedException if the file is locked by another user and {@link #force(boolean)} has not been
* set to <code>true</code>.
*/
public UnlockCommandResult execute() {
UnlockCommandRequest unlockCommandRequest = new UnlockCommandRequest();
unlockCommandRequest.setFile(file);
unlockCommandRequest.setForce(force);
return fileLockCommand.unlock(unlockCommandRequest);
}
}
}

View File

@@ -0,0 +1,78 @@
/*
* 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.api;
import sonia.scm.ExceptionWithContext;
import sonia.scm.repository.NamespaceAndName;
import static sonia.scm.ContextEntry.ContextBuilder.entity;
/**
* Exception thrown whenever a locked file should be modified or locked/unlocked by a user that does not hold the lock.
*
* @since 2.26.0
*/
public class FileLockedException extends ExceptionWithContext {
private static final String CODE = "3mSmwOtOd1";
private final FileLock conflictingLock;
/**
* Creates the exception.
*
* @param namespaceAndName The namespace and name of the repository.
* @param lock The lock causing this exception.
*/
public FileLockedException(NamespaceAndName namespaceAndName, FileLock lock) {
this(namespaceAndName, lock, "");
}
/**
* Creates the exception with an additional message.
*
* @param namespaceAndName The namespace and name of the repository.
* @param lock The lock causing this exception.
* @param additionalMessage An additional message that will be appended to the default message.
*/
public FileLockedException(NamespaceAndName namespaceAndName, FileLock lock, String additionalMessage) {
super(
entity("File Lock", lock.getPath()).in(namespaceAndName).build(),
("File " + lock.getPath() + " locked by " + lock.getUserId() + ". " + additionalMessage).trim());
this.conflictingLock = lock;
}
@Override
public String getCode() {
return CODE;
}
/**
* The lock that caused this exception.
*/
public FileLock getConflictingLock() {
return conflictingLock;
}
}

View File

@@ -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.repository.api;
import lombok.AllArgsConstructor;
/**
* Result of a lock command.
*
* @since 2.26.0
*/
@AllArgsConstructor
public class LockCommandResult {
private final boolean successful;
/**
* If <code>true</code>, the lock has been set successfully.
*/
public boolean isSuccessful() {
return successful;
}
}

View File

@@ -477,6 +477,19 @@ public final class RepositoryService implements Closeable {
return new MirrorCommandBuilder(provider.getMirrorCommand(), repository); return new MirrorCommandBuilder(provider.getMirrorCommand(), repository);
} }
/**
* Lock and unlock files.
*
* @return instance of {@link FileLockCommandBuilder}
* @throws CommandNotSupportedException if the command is not supported
* by the implementation of the repository service provider.
* @since 2.26.0
*/
public FileLockCommandBuilder getLockCommand() {
LOG.debug("create lock command for repository {}", repository);
return new FileLockCommandBuilder(provider.getFileLockCommand(), repository);
}
/** /**
* Returns true if the command is supported by the repository service. * Returns true if the command is supported by the repository service.
* *

View File

@@ -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.repository.api;
import lombok.AllArgsConstructor;
/**
* Result of a unlock command.
*
* @since 2.26.0
*/
@AllArgsConstructor
public class UnlockCommandResult {
private boolean successful;
/**
* If <code>true</code>, the lock has been removed successfully.
*/
public boolean isSuccessful() {
return successful;
}
}

View File

@@ -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.repository.spi;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.LockCommandResult;
import sonia.scm.repository.api.UnlockCommandResult;
import java.util.Collection;
import java.util.Optional;
/**
* Interface for lock implementations.
*
* @since 2.26.0
*/
public interface FileLockCommand {
/**
* Locks a given file.
*
* @param request The details of the lock creation.
* @return The result of the lock creation.
* @throws sonia.scm.repository.api.FileLockedException if the file is already locked.
*/
LockCommandResult lock(LockCommandRequest request);
/**
* Unlocks a given file.
*
* @param request The details of the lock removal.
* @return The result of the lock removal.
* @throws sonia.scm.repository.api.FileLockedException if the file is locked and the lock cannot be removed.
*/
UnlockCommandResult unlock(UnlockCommandRequest request);
/**
* Returns the lock of a file, if it exists.
*
* @param request Details of the status request.
* @return {@link Optional} with the lock, if the file is locked,
* or {@link Optional#empty()}, if the file is not locked
*/
Optional<FileLock> status(LockStatusCommandRequest request);
/**
* Returns all locks for the repository.
*/
Collection<FileLock> getAll();
}

View File

@@ -0,0 +1,38 @@
/*
* 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 lombok.Data;
/**
* Request used to lock a file.
*
* @since 2.26.0
*/
@Data
public final class LockCommandRequest {
private String file;
}

View File

@@ -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.spi;
import lombok.Data;
@Data
public final class LockStatusCommandRequest {
private String file;
}

View File

@@ -229,10 +229,9 @@ public abstract class RepositoryServiceProvider implements Closeable
* *
* @return * @return
*/ */
@SuppressWarnings("unchecked")
public Set<Feature> getSupportedFeatures() public Set<Feature> getSupportedFeatures()
{ {
return Collections.EMPTY_SET; return Collections.emptySet();
} }
/** /**
@@ -305,4 +304,11 @@ public abstract class RepositoryServiceProvider implements Closeable
public MirrorCommand getMirrorCommand() { public MirrorCommand getMirrorCommand() {
throw new CommandNotSupportedException(Command.MIRROR); throw new CommandNotSupportedException(Command.MIRROR);
} }
/**
* @since 2.26.0
*/
public FileLockCommand getFileLockCommand() {
throw new CommandNotSupportedException(Command.FILE_LOCK);
}
} }

View File

@@ -0,0 +1,39 @@
/*
* 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 lombok.Data;
/**
* Request to unlock a file.
*
* @since 2.26.0
*/
@Data
public final class UnlockCommandRequest {
private String file;
private boolean force;
}

View File

@@ -0,0 +1,47 @@
/*
* 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.web;
import sonia.scm.plugin.ExtensionPoint;
import javax.servlet.http.HttpServletRequest;
/**
* This can be used to determine, whether a web request should be handled as a scm client request.
*
* @since 2.26.0
*/
@ExtensionPoint
public interface ScmClientDetector {
/**
* Checks whether the given request and/or the userAgent imply a request from a scm client.
*
* @param request The request to check.
* @param userAgent The {@link UserAgent} for the request.
* @return <code>true</code> if the given request was sent by an scm client.
*/
boolean isScmClient(HttpServletRequest request, UserAgent userAgent);
}

View File

@@ -0,0 +1,118 @@
/*
* 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 com.github.legman.Subscribe;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.EagerSingleton;
import sonia.scm.plugin.Extension;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.InternalRepositoryException;
import sonia.scm.repository.Modifications;
import sonia.scm.repository.PreReceiveRepositoryHookEvent;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import javax.inject.Inject;
import java.io.IOException;
@Extension
@EagerSingleton
public class FileLockPreCommitHook {
private static final Logger LOG = LoggerFactory.getLogger(FileLockPreCommitHook.class);
private final GitFileLockStoreFactory fileLockStoreFactory;
private final RepositoryServiceFactory serviceFactory;
@Inject
public FileLockPreCommitHook(GitFileLockStoreFactory fileLockStoreFactory, RepositoryServiceFactory serviceFactory) {
this.fileLockStoreFactory = fileLockStoreFactory;
this.serviceFactory = serviceFactory;
}
@Subscribe(async = false)
public void checkForLocks(PreReceiveRepositoryHookEvent event) {
Repository repository = event.getRepository();
LOG.trace("checking for locks during push in repository {}", repository);
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = fileLockStoreFactory.create(repository);
if (!gitFileLockStore.hasLocks()) {
LOG.trace("no locks found in repository {}", repository);
return;
}
try (RepositoryService service = serviceFactory.create(repository)) {
checkPaths(event, gitFileLockStore, service);
} catch (IOException e) {
throw new InternalRepositoryException(repository, "could not check locks", e);
}
}
private void checkPaths(PreReceiveRepositoryHookEvent event, GitFileLockStoreFactory.GitFileLockStore gitFileLockStore, RepositoryService service) throws IOException {
new Checker(gitFileLockStore, service)
.checkPaths(event.getContext().getChangesetProvider().getChangesets());
}
private static class Checker {
private final GitFileLockStoreFactory.GitFileLockStore fileLockStore;
private final RepositoryService service;
private Checker(GitFileLockStoreFactory.GitFileLockStore fileLockStore, RepositoryService service) {
this.fileLockStore = fileLockStore;
this.service = service;
}
private void checkPaths(Iterable<Changeset> changesets) throws IOException {
for (Changeset c : changesets) {
checkPaths(c);
}
}
private void checkPaths(Changeset changeset) throws IOException {
LOG.trace("checking changeset {}", changeset.getId());
Modifications modifications = service.getModificationsCommand()
.revision(changeset.getId())
.getModifications();
if (modifications != null) {
checkPaths(modifications);
} else {
LOG.trace("no modifications for the changeset {} found", changeset.getId());
}
}
private void checkPaths(Modifications modifications) {
check(modifications.getEffectedPaths());
}
private void check(Iterable<String> modifiedPaths) {
for (String path : modifiedPaths) {
fileLockStore.assertModifiable(path);
}
}
}
}

View File

@@ -0,0 +1,76 @@
/*
* 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.repository.api.FileLock;
import sonia.scm.repository.api.LockCommandResult;
import sonia.scm.repository.api.UnlockCommandResult;
import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore;
import javax.inject.Inject;
import java.util.Collection;
import java.util.Optional;
public class GitFileLockCommand implements FileLockCommand {
private final GitContext context;
private final GitFileLockStoreFactory lockStoreFactory;
@Inject
public GitFileLockCommand(GitContext context, GitFileLockStoreFactory lockStoreFactory) {
this.context = context;
this.lockStoreFactory = lockStoreFactory;
}
@Override
public LockCommandResult lock(LockCommandRequest request) {
GitFileLockStore lockStore = getLockStore();
lockStore.put(request.getFile());
return new LockCommandResult(true);
}
@Override
public UnlockCommandResult unlock(UnlockCommandRequest request) {
GitFileLockStore lockStore = getLockStore();
lockStore.remove(request.getFile(), request.isForce());
return new UnlockCommandResult(true);
}
@Override
public Optional<FileLock> status(LockStatusCommandRequest request) {
GitFileLockStore lockStore = getLockStore();
return lockStore.getLock(request.getFile());
}
@Override
public Collection<FileLock> getAll() {
GitFileLockStore lockStore = getLockStore();
return lockStore.getAll();
}
private GitFileLockStore getLockStore() {
return lockStoreFactory.create(context.getRepository());
}
}

View File

@@ -0,0 +1,250 @@
/*
* 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 lombok.Data;
import lombok.NoArgsConstructor;
import org.apache.shiro.SecurityUtils;
import sonia.scm.repository.Repository;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.security.KeyGenerator;
import sonia.scm.store.DataStore;
import sonia.scm.store.DataStoreFactory;
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.XmlTransient;
import java.time.Clock;
import java.time.Instant;
import java.util.Collection;
import java.util.HashMap;
import java.util.Map;
import java.util.Optional;
import java.util.TreeMap;
import java.util.function.Supplier;
import static java.util.Collections.emptyList;
import static java.util.Optional.empty;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.toList;
@Singleton
public final class GitFileLockStoreFactory {
private static final String STORE_ID = "locks";
private final DataStoreFactory dataStoreFactory;
private final KeyGenerator keyGenerator;
private final Clock clock;
private final Supplier<String> currentUser;
@Inject
public GitFileLockStoreFactory(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator) {
this(dataStoreFactory,
keyGenerator,
Clock.systemDefaultZone(),
() -> SecurityUtils.getSubject().getPrincipal().toString());
}
GitFileLockStoreFactory(DataStoreFactory dataStoreFactory, KeyGenerator keyGenerator, Clock clock, Supplier<String> currentUser) {
this.dataStoreFactory = dataStoreFactory;
this.keyGenerator = keyGenerator;
this.clock = clock;
this.currentUser = currentUser;
}
public GitFileLockStore create(Repository repository) {
return new GitFileLockStore(repository);
}
public final class GitFileLockStore {
private final Repository repository;
private final DataStore<StoreEntry> store;
public GitFileLockStore(Repository repository) {
this.repository = repository;
this.store =
dataStoreFactory
.withType(StoreEntry.class)
.withName("file-locks")
.forRepository(repository)
.build();
}
public boolean hasLocks() {
return !readEntry().isEmpty();
}
public FileLock put(String file) {
StoreEntry storeEntry = readEntry();
Optional<FileLock> existingLock = storeEntry.get(file);
if (existingLock.isPresent() && !existingLock.get().getUserId().equals(currentUser.get())) {
throw createLockException(existingLock.get());
}
FileLock newLock = new FileLock(file, keyGenerator.createKey(), currentUser.get(), Instant.now(clock));
storeEntry.add(newLock);
store(storeEntry);
return newLock;
}
public Optional<FileLock> remove(String file, boolean force) {
StoreEntry storeEntry = readEntry();
Optional<FileLock> existingFileLock = storeEntry.get(file);
if (existingFileLock.isPresent()) {
if (!force && !currentUser.get().equals(existingFileLock.get().getUserId())) {
throw createLockException(existingFileLock.get());
}
storeEntry.remove(file);
store(storeEntry);
}
return existingFileLock;
}
public Optional<FileLock> removeById(String id, boolean force) {
StoreEntry storeEntry = readEntry();
return storeEntry.getById(id).flatMap(lock -> remove(lock.getPath(), force));
}
public Optional<FileLock> getLock(String file) {
return readEntry().get(file);
}
public void assertModifiable(String file) {
getLock(file)
.filter(lock -> !lock.getUserId().equals(currentUser.get()))
.ifPresent(lock -> {
throw createLockException(lock);
});
}
public Optional<FileLock> getById(String id) {
return readEntry().getById(id);
}
public Collection<FileLock> getAll() {
return readEntry().getAll();
}
private StoreEntry readEntry() {
return store.getOptional(STORE_ID).orElse(new StoreEntry());
}
private void store(StoreEntry storeEntry) {
store.put(STORE_ID, storeEntry);
}
private FileLockedException createLockException(FileLock lock) {
return new FileLockedException(repository.getNamespaceAndName(), lock, "Lock or unlock with git lfs lock/unlock <file>.");
}
}
@XmlRootElement(name = "file-locks")
@XmlAccessorType(XmlAccessType.PROPERTY)
private static class StoreEntry {
private Map<String, StoredFileLock> files = new TreeMap<>();
@XmlTransient
private final Map<String, StoredFileLock> ids = new HashMap<>();
public void setFiles(Map<String, StoredFileLock> files) {
this.files = files;
files.values().forEach(
lock -> ids.put(lock.getId(), lock)
);
}
public Map<String, StoredFileLock> getFiles() {
return files;
}
Optional<FileLock> get(String file) {
if (files == null) {
return empty();
}
return ofNullable(files.get(file)).map(StoredFileLock::toFileLock);
}
Optional<FileLock> getById(String id) {
if (files == null) {
return empty();
}
return ofNullable(ids.get(id)).map(StoredFileLock::toFileLock);
}
void add(FileLock lock) {
if (files == null) {
files = new TreeMap<>();
}
StoredFileLock newLock = new StoredFileLock(lock);
files.put(lock.getPath(), newLock);
ids.put(lock.getId(), newLock);
}
void remove(String file) {
if (files == null) {
return;
}
StoredFileLock existingLock = files.remove(file);
if (existingLock != null) {
ids.remove(existingLock.getId());
}
}
Collection<FileLock> getAll() {
if (files == null) {
return emptyList();
}
return files.values().stream().map(StoredFileLock::toFileLock).collect(toList());
}
boolean isEmpty() {
return files == null || files.isEmpty();
}
}
@Data
@NoArgsConstructor
private static class StoredFileLock {
private String id;
private String path;
private String userId;
private long timestamp;
StoredFileLock(FileLock fileLock) {
this.id = fileLock.getId();
this.path = fileLock.getPath();
this.userId = fileLock.getUserId();
this.timestamp = fileLock.getTimestamp().getEpochSecond();
}
FileLock toFileLock() {
return new FileLock(path, id, userId, Instant.ofEpochSecond(timestamp));
}
}
}

View File

@@ -57,7 +57,8 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider {
Command.MODIFY, Command.MODIFY,
Command.BUNDLE, Command.BUNDLE,
Command.UNBUNDLE, Command.UNBUNDLE,
Command.MIRROR Command.MIRROR,
Command.FILE_LOCK
); );
protected static final Set<Feature> FEATURES = EnumSet.of( protected static final Set<Feature> FEATURES = EnumSet.of(
@@ -180,6 +181,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider {
return commandInjector.getInstance(GitMirrorCommand.class); return commandInjector.getInstance(GitMirrorCommand.class);
} }
@Override
public FileLockCommand getFileLockCommand() {
return commandInjector.getInstance(GitFileLockCommand.class);
}
@Override @Override
public Set<Command> getSupportedCommands() { public Set<Command> getSupportedCommands() {
return COMMANDS; return COMMANDS;

View File

@@ -0,0 +1,41 @@
/*
* 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.web;
import sonia.scm.plugin.Extension;
import javax.servlet.http.HttpServletRequest;
@Extension
public class GitLfsLockApiDetector implements ScmClientDetector {
public static final String LOCK_APPLICATION_TYPE = "application/vnd.git-lfs+json";
@Override
public boolean isScmClient(HttpServletRequest request, UserAgent userAgent) {
return LOCK_APPLICATION_TYPE.equals(request.getHeader("Content-Type"))
|| LOCK_APPLICATION_TYPE.equals(request.getHeader("Accept"));
}
}

View File

@@ -0,0 +1,505 @@
/*
* 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.web;
import com.fasterxml.jackson.annotation.JsonIgnore;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.annotation.JsonProperty;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.github.sdorra.ssp.PermissionCheck;
import lombok.AllArgsConstructor;
import lombok.Data;
import lombok.Getter;
import lombok.Value;
import org.apache.shiro.SecurityUtils;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import sonia.scm.TransactionId;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryPermissions;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore;
import sonia.scm.user.DisplayUser;
import sonia.scm.user.UserDisplayManager;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.util.Collection;
import java.util.Collections;
import java.util.List;
import java.util.Map;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Stream;
import static com.google.common.base.Strings.isNullOrEmpty;
import static java.lang.Integer.parseInt;
import static java.lang.Math.max;
import static java.lang.Math.min;
import static java.util.Collections.emptyList;
import static java.util.Optional.empty;
import static java.util.Optional.ofNullable;
import static java.util.stream.Collectors.groupingBy;
import static java.util.stream.Collectors.toList;
import static javax.servlet.http.HttpServletResponse.SC_BAD_REQUEST;
import static javax.servlet.http.HttpServletResponse.SC_CONFLICT;
import static javax.servlet.http.HttpServletResponse.SC_CREATED;
import static javax.servlet.http.HttpServletResponse.SC_FORBIDDEN;
import static javax.servlet.http.HttpServletResponse.SC_NOT_FOUND;
import static javax.servlet.http.HttpServletResponse.SC_OK;
public class LfsLockingProtocolServlet extends HttpServlet {
private static final Logger LOG = LoggerFactory.getLogger(LfsLockingProtocolServlet.class);
private static final Pattern GET_PATH_PATTERN = Pattern.compile(".*\\.git/info/lfs/locks");
private static final Pattern POST_PATH_PATTERN = Pattern.compile(".*\\.git/info/lfs/locks(?:/(verify|(\\w+)/unlock))?");
private static final int DEFAULT_LIMIT = 1000;
private static final int LOWER_LIMIT = 10;
private final Repository repository;
private final GitFileLockStore lockStore;
private final UserDisplayManager userDisplayManager;
private final ObjectMapper objectMapper;
private final int defaultLimit;
private final int lowerLimit;
public LfsLockingProtocolServlet(Repository repository, GitFileLockStore lockStore, UserDisplayManager userDisplayManager, ObjectMapper objectMapper) {
this(repository, lockStore, userDisplayManager, objectMapper, DEFAULT_LIMIT, LOWER_LIMIT);
}
LfsLockingProtocolServlet(Repository repository, GitFileLockStore lockStore, UserDisplayManager userDisplayManager, ObjectMapper objectMapper, int defaultLimit, int lowerLimit) {
this.repository = repository;
this.lockStore = lockStore;
this.userDisplayManager = userDisplayManager;
this.objectMapper = objectMapper;
this.defaultLimit = defaultLimit;
this.lowerLimit = lowerLimit;
}
@Override
protected void doGet(HttpServletRequest req, HttpServletResponse resp) {
LOG.trace("processing GET request");
new Handler(req, resp).handleGet();
}
@Override
protected void doPost(HttpServletRequest req, HttpServletResponse resp) {
LOG.trace("processing POST request");
new Handler(req, resp).handlePost();
}
private class Handler {
private final HttpServletRequest req;
private final HttpServletResponse resp;
public Handler(HttpServletRequest req, HttpServletResponse resp) {
this.req = req;
this.resp = resp;
}
private void handleGet() {
if (getRequestValidator().verifyRequest()) {
if (!isNullOrEmpty(req.getParameter("path"))) {
handleSinglePathRequest();
} else if (!isNullOrEmpty(req.getParameter("id"))) {
handleSingleIdRequest();
} else {
handleGetAllRequest();
}
}
}
private void handlePost() {
PostRequestValidator validator = postRequestValidator();
if (validator.verifyRequest()) {
if (validator.isLockRequest()) {
handleLockRequest();
} else if (validator.isVerifyRequest()) {
handleVerifyRequest();
} else {
handleUnlockRequest(validator.getLockId());
}
}
}
private void handleSinglePathRequest() {
LOG.trace("request limited to path: {}", req.getParameter("path"));
sendResult(SC_OK, new LocksListDto(lockStore.getLock(req.getParameter("path"))));
}
private void handleSingleIdRequest() {
String id = req.getParameter("id");
LOG.trace("request limited to id: {}", id);
sendResult(SC_OK, new LocksListDto(lockStore.getById(id)));
}
private void handleGetAllRequest() {
int limit = getLimit();
int cursor = getCursor();
if (limit < 0 || cursor < 0) {
return;
}
Collection<FileLock> allLocks = lockStore.getAll();
Stream<FileLock> resultLocks = limit(allLocks, limit, cursor);
LocksListDto result = new LocksListDto(resultLocks, computeNextCursor(limit, cursor, allLocks));
LOG.trace("created list result with {} locks and next cursor {}", result.getLocks().size(), result.getNextCursor());
sendResult(SC_OK, result);
}
private String computeNextCursor(int limit, int cursor, Collection<FileLock> allLocks) {
return allLocks.size() > cursor + limit ? Integer.toString(cursor + limit) : null;
}
private Stream<FileLock> limit(Collection<FileLock> allLocks, int limit, int cursor) {
return allLocks.stream().skip(cursor).limit(limit);
}
private int getLimit() {
String limitString = req.getParameter("limit");
if (isNullOrEmpty(limitString)) {
LOG.trace("using default limit {}", defaultLimit);
return defaultLimit;
}
try {
return getEffectiveLimit(parseInt(limitString));
} catch (NumberFormatException e) {
LOG.trace("illegal limit parameter '{}'", limitString);
sendError(SC_BAD_REQUEST, "Illegal limit parameter");
return -1;
}
}
private int getEffectiveLimit(int limit) {
int effectiveLimit = max(lowerLimit, min(defaultLimit, limit));
LOG.trace("using limit {}", effectiveLimit);
return effectiveLimit;
}
private int getCursor() {
String cursor = req.getParameter("cursor");
return getCursor(cursor);
}
private int getCursor(String cursor) {
if (isNullOrEmpty(cursor)) {
return 0;
}
try {
int effectiveCursor = parseInt(cursor);
LOG.trace("starting at position {}", effectiveCursor);
return effectiveCursor;
} catch (NumberFormatException e) {
LOG.trace("illegal cursor parameter '{}'", cursor);
sendError(SC_BAD_REQUEST, "Illegal cursor parameter");
return -1;
}
}
private void handleLockRequest() {
LOG.trace("processing lock request");
readObject(LockCreateDto.class).ifPresent(
lockCreate -> {
if (isNullOrEmpty(lockCreate.path)) {
sendError(SC_BAD_REQUEST, "Illegal input");
} else {
try {
FileLock createdLock = lockStore.put(lockCreate.getPath());
sendResult(SC_CREATED, new SingleLockDto(createdLock));
} catch (FileLockedException e) {
FileLock conflictingLock = e.getConflictingLock();
sendError(SC_CONFLICT, new ConflictDto("already created lock", conflictingLock));
}
}
}
);
}
private void handleVerifyRequest() {
LOG.trace("processing verify request");
readObject(VerifyDto.class).ifPresent(
verify -> {
Collection<FileLock> allLocks = lockStore.getAll();
int cursor = getCursor(verify.getCursor());
int limit = getEffectiveLimit(verify.getLimit());
if (limit < 0 || cursor < 0) {
return;
}
Stream<FileLock> resultLocks = limit(allLocks, limit, cursor);
VerifyResultDto result = new VerifyResultDto(resultLocks, computeNextCursor(limit, cursor, allLocks));
LOG.trace("created list result with {} 'our' locks, {} 'their' locks, and next cursor {}", result.getOurs().size(), result.getTheirs().size(), result.getNextCursor());
sendResult(SC_OK, result);
}
);
}
private void handleUnlockRequest(String lockId) {
LOG.trace("processing unlock request");
readObject(UnlockDto.class).ifPresent(
unlock -> {
try {
Optional<FileLock> deletedLock = lockStore.removeById(lockId, unlock.isForce());
if (deletedLock.isPresent()) {
sendResult(SC_OK, new SingleLockDto(deletedLock.get()));
} else {
sendError(SC_NOT_FOUND, "No such lock");
}
} catch (FileLockedException e) {
FileLock conflictingLock = e.getConflictingLock();
sendError(SC_FORBIDDEN, "locked by " + conflictingLock.getUserId());
}
}
);
}
private <T> Optional<T> readObject(Class<T> resultType) {
try {
return ofNullable(objectMapper.readValue(req.getInputStream(), resultType));
} catch (IOException e) {
LOG.info("got exception reading input", e);
sendError(SC_BAD_REQUEST, "Could not read input");
return empty();
}
}
private GetRequestValidator getRequestValidator() {
return new GetRequestValidator();
}
private PostRequestValidator postRequestValidator() {
return new PostRequestValidator();
}
private abstract class RequestValidator {
boolean verifyRequest() {
return verifyPath() && verifyPermission();
}
private boolean verifyPermission() {
if (!getPermission().isPermitted()) {
sendError(HttpServletResponse.SC_FORBIDDEN, "You must have push access to create a lock");
return false;
}
return true;
}
abstract PermissionCheck getPermission();
private boolean verifyPath() {
if (!isPathValid(req.getPathInfo())) {
LOG.trace("got illegal path {}", req.getPathInfo());
sendError(HttpServletResponse.SC_BAD_REQUEST, "wrong URL for locks api");
return false;
}
return true;
}
abstract boolean isPathValid(String path);
}
private class GetRequestValidator extends RequestValidator {
@Override
PermissionCheck getPermission() {
return RepositoryPermissions.pull(repository);
}
@Override
boolean isPathValid(String path) {
return GET_PATH_PATTERN.matcher(path).matches();
}
}
private class PostRequestValidator extends RequestValidator {
private Matcher matcher;
@Override
PermissionCheck getPermission() {
return RepositoryPermissions.push(repository);
}
@Override
boolean isPathValid(String path) {
matcher = POST_PATH_PATTERN.matcher(path);
return matcher.matches();
}
boolean isLockRequest() {
return matcher.group(1) == null;
}
boolean isVerifyRequest() {
String subPath = matcher.group(1);
return subPath.equals("verify");
}
public String getLockId() {
return matcher.group(2);
}
}
private void sendResult(int statusCode, Object result) {
LOG.trace("Completing with status code {}", statusCode);
resp.setStatus(statusCode);
resp.setContentType("application/vnd.git-lfs+json");
try {
objectMapper.writeValue(resp.getOutputStream(), result);
} catch (IOException e) {
LOG.warn("Failed to send result to client", e);
}
}
private void sendError(int statusCode, String message) {
LOG.trace("Sending error message '{}'", message);
sendError(statusCode, new ErrorMessageDto(message));
}
private void sendError(int statusCode, Object error) {
LOG.trace("Completing with error, status code {}", statusCode);
resp.setStatus(statusCode);
try {
objectMapper.writeValue(resp.getOutputStream(), error);
} catch (IOException e) {
LOG.warn("Failed to send error to client", e);
}
}
}
@Value
private class ConflictDto extends ErrorMessageDto {
private LockDto lock;
public ConflictDto(String message, FileLock lock) {
super(message);
this.lock = new LockDto(lock);
}
}
@Getter
@AllArgsConstructor
private class ErrorMessageDto {
private String message;
@JsonInclude(JsonInclude.Include.NON_NULL)
private String requestId;
public ErrorMessageDto(String message) {
this.message = message;
this.requestId = TransactionId.get().orElse(null);
}
}
@Data
static class LockCreateDto {
private String path;
}
@Data
static class VerifyDto {
private String cursor;
private int limit = Integer.MAX_VALUE;
}
@Data
static class UnlockDto {
private boolean force;
}
@Value
private class VerifyResultDto {
private Collection<LockDto> ours;
private Collection<LockDto> theirs;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonProperty("next_cursor")
private String nextCursor;
VerifyResultDto(Stream<FileLock> locks, String nextCursor) {
String userId = SecurityUtils.getSubject().getPrincipals().oneByType(String.class);
Map<Boolean, List<LockDto>> groupedLocks = locks.map(LockDto::new).collect(groupingBy(lock -> userId.equals(lock.getUserId())));
ours = groupedLocks.getOrDefault(true, emptyList());
theirs = groupedLocks.getOrDefault(false, emptyList());
this.nextCursor = nextCursor;
}
}
@Getter
private class OwnerDto {
private String name;
OwnerDto(String userId) {
this.name = userDisplayManager.get(userId).map(DisplayUser::getDisplayName).orElse(userId);
}
}
@Getter
private class SingleLockDto {
private LockDto lock;
SingleLockDto(FileLock lock) {
this.lock = new LockDto(lock);
}
}
@Getter
private class LockDto {
private String id;
private String path;
@JsonProperty("locked_at")
private String lockedAt;
private OwnerDto owner;
@JsonIgnore
private String userId;
LockDto(FileLock lock) {
this.id = lock.getId();
this.path = lock.getPath();
this.lockedAt = lock.getTimestamp().toString();
this.owner = new OwnerDto(lock.getUserId());
this.userId = lock.getUserId();
}
}
@Getter
private class LocksListDto {
private List<LockDto> locks;
@JsonInclude(JsonInclude.Include.NON_EMPTY)
@JsonProperty("next_cursor")
private String nextCursor;
LocksListDto(Optional<FileLock> locks) {
this.locks = locks.map(LockDto::new).map(Collections::singletonList).orElse(emptyList());
}
LocksListDto(Stream<FileLock> locks, String nextCursor) {
this.locks = locks.map(LockDto::new).collect(toList());
this.nextCursor = nextCursor;
}
}
}

View File

@@ -40,6 +40,7 @@ import javax.servlet.ServletException;
import javax.servlet.http.HttpServlet; import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import javax.ws.rs.core.MediaType;
import java.io.IOException; import java.io.IOException;
import java.util.regex.Pattern; import java.util.regex.Pattern;
@@ -65,6 +66,7 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet
/** the logger for ScmGitServlet */ /** the logger for ScmGitServlet */
private static final Logger logger = getLogger(ScmGitServlet.class); private static final Logger logger = getLogger(ScmGitServlet.class);
public static final MediaType LFS_LOCKING_MEDIA_TYPE = MediaType.valueOf("application/vnd.git-lfs+json");
//~--- constructors --------------------------------------------------------- //~--- constructors ---------------------------------------------------------
@@ -109,6 +111,10 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet
HttpServlet servlet = lfsServletFactory.createFileLfsServletFor(repository, request); HttpServlet servlet = lfsServletFactory.createFileLfsServletFor(repository, request);
logger.trace("handle lfs file transfer request"); logger.trace("handle lfs file transfer request");
handleGitLfsRequest(servlet, request, response, repository); handleGitLfsRequest(servlet, request, response, repository);
} else if (isLfsLockingAPIRequest(request)) {
HttpServlet servlet = lfsServletFactory.createLockServletFor(repository);
logger.trace("handle lfs lock request");
handleGitLfsLockingRequest(servlet, request, response, repository);
} else if (isRegularGitAPIRequest(request)) { } else if (isRegularGitAPIRequest(request)) {
logger.trace("handle regular git request"); logger.trace("handle regular git request");
// continue with the regular git Backend // continue with the regular git Backend
@@ -119,10 +125,34 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet
} }
} }
private void handleGitLfsLockingRequest(HttpServlet servlet, HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException {
if (repositoryRequestListenerUtil.callListeners(request, response, repository)) {
servlet.service(request, response);
} else if (logger.isDebugEnabled()) {
logger.debug("request aborted by repository request listener");
}
}
private boolean isRegularGitAPIRequest(HttpServletRequest request) { private boolean isRegularGitAPIRequest(HttpServletRequest request) {
return REGEX_GITHTTPBACKEND.matcher(HttpUtil.getStrippedURI(request)).matches(); return REGEX_GITHTTPBACKEND.matcher(HttpUtil.getStrippedURI(request)).matches();
} }
private boolean isLfsLockingAPIRequest(HttpServletRequest request) {
return isLfsLockingMediaType(request, "Content-Type")
|| isLfsLockingMediaType(request, "Accept");
}
private boolean isLfsLockingMediaType(HttpServletRequest request, String header) {
try {
MediaType requestMediaType = MediaType.valueOf(request.getHeader(header));
return !requestMediaType.isWildcardType()
&& !requestMediaType.isWildcardSubtype()
&& LFS_LOCKING_MEDIA_TYPE.isCompatible(requestMediaType);
} catch (IllegalArgumentException e) {
return false;
}
}
private void handleGitLfsRequest(HttpServlet servlet, HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException { private void handleGitLfsRequest(HttpServlet servlet, HttpServletRequest request, HttpServletResponse response, Repository repository) throws ServletException, IOException {
if (repositoryRequestListenerUtil.callListeners(request, response, repository)) { if (repositoryRequestListenerUtil.callListeners(request, response, repository)) {
servlet.service(request, response); servlet.service(request, response);

View File

@@ -24,6 +24,7 @@
package sonia.scm.web.lfs.servlet; package sonia.scm.web.lfs.servlet;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.google.common.annotations.VisibleForTesting; import com.google.common.annotations.VisibleForTesting;
import org.eclipse.jgit.lfs.server.LargeFileRepository; import org.eclipse.jgit.lfs.server.LargeFileRepository;
import org.eclipse.jgit.lfs.server.LfsProtocolServlet; import org.eclipse.jgit.lfs.server.LfsProtocolServlet;
@@ -31,8 +32,11 @@ import org.eclipse.jgit.lfs.server.fs.FileLfsServlet;
import org.slf4j.Logger; import org.slf4j.Logger;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
import sonia.scm.repository.Repository; import sonia.scm.repository.Repository;
import sonia.scm.repository.spi.GitFileLockStoreFactory;
import sonia.scm.store.BlobStore; import sonia.scm.store.BlobStore;
import sonia.scm.user.UserDisplayManager;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.web.LfsLockingProtocolServlet;
import sonia.scm.web.lfs.LfsAccessTokenFactory; import sonia.scm.web.lfs.LfsAccessTokenFactory;
import sonia.scm.web.lfs.LfsBlobStoreFactory; import sonia.scm.web.lfs.LfsBlobStoreFactory;
import sonia.scm.web.lfs.ScmBlobLfsRepository; import sonia.scm.web.lfs.ScmBlobLfsRepository;
@@ -56,11 +60,17 @@ public class LfsServletFactory {
private final LfsBlobStoreFactory lfsBlobStoreFactory; private final LfsBlobStoreFactory lfsBlobStoreFactory;
private final LfsAccessTokenFactory tokenFactory; private final LfsAccessTokenFactory tokenFactory;
private final GitFileLockStoreFactory lockStoreFactory;
private final UserDisplayManager userDisplayManager;
private final ObjectMapper objectMapper;
@Inject @Inject
public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory, LfsAccessTokenFactory tokenFactory) { public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory, LfsAccessTokenFactory tokenFactory, GitFileLockStoreFactory lockStoreFactory, UserDisplayManager userDisplayManager, ObjectMapper objectMapper) {
this.lfsBlobStoreFactory = lfsBlobStoreFactory; this.lfsBlobStoreFactory = lfsBlobStoreFactory;
this.tokenFactory = tokenFactory; this.tokenFactory = tokenFactory;
this.lockStoreFactory = lockStoreFactory;
this.userDisplayManager = userDisplayManager;
this.objectMapper = objectMapper;
} }
/** /**
@@ -91,6 +101,11 @@ public class LfsServletFactory {
return new ScmFileTransferServlet(lfsBlobStoreFactory.getLfsBlobStore(repository)); return new ScmFileTransferServlet(lfsBlobStoreFactory.getLfsBlobStore(repository));
} }
public LfsLockingProtocolServlet createLockServletFor(Repository repository) {
LOG.trace("create lfs lock servlet for repository {}", repository);
return new LfsLockingProtocolServlet(repository, lockStoreFactory.create(repository), userDisplayManager, objectMapper);
}
/** /**
* Build the complete URI, under which the File Transfer API for this repository will be will be reachable. * Build the complete URI, under which the File Transfer API for this repository will be will be reachable.
* *

View File

@@ -0,0 +1,151 @@
/*
* 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 org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.Answers;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.repository.Changeset;
import sonia.scm.repository.Modifications;
import sonia.scm.repository.Modified;
import sonia.scm.repository.PreReceiveRepositoryHookEvent;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryHookEvent;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.api.HookChangesetBuilder;
import sonia.scm.repository.api.HookContext;
import sonia.scm.repository.api.ModificationsCommandBuilder;
import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory;
import java.io.IOException;
import static java.util.Arrays.asList;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static sonia.scm.repository.RepositoryHookType.PRE_RECEIVE;
@ExtendWith(MockitoExtension.class)
class FileLockPreCommitHookTest {
private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold();
@Mock
private GitFileLockStoreFactory fileLockStoreFactory;
@Mock
private GitFileLockStoreFactory.GitFileLockStore fileLockStore;
@Mock
private RepositoryServiceFactory serviceFactory;
@Mock
private RepositoryService service;
@InjectMocks
private FileLockPreCommitHook hook;
@Mock
private HookContext context;
@BeforeEach
void initLockStore() {
when(fileLockStoreFactory.create(REPOSITORY))
.thenReturn(fileLockStore);
}
@Test
void shouldIgnoreRepositoriesWithoutLockSupport() {
PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE));
hook.checkForLocks(event);
verify(serviceFactory, never()).create(any(Repository.class));
}
@Nested
class WithLocks {
@Mock
private HookChangesetBuilder changesetBuilder;
@Mock(answer = Answers.RETURNS_SELF)
private ModificationsCommandBuilder modificationsCommand;
private String currentChangesetId;
@BeforeEach
void initService() {
when(serviceFactory.create(REPOSITORY))
.thenReturn(service);
when(service.getModificationsCommand())
.thenReturn(modificationsCommand);
when(context.getChangesetProvider())
.thenReturn(changesetBuilder);
}
@BeforeEach
void initLocks() {
when(fileLockStore.hasLocks()).thenReturn(true);
}
@BeforeEach
void initModifications() throws IOException {
when(modificationsCommand.revision(anyString()))
.thenAnswer(invocation -> {
currentChangesetId = invocation.getArgument(0, String.class);
return modificationsCommand;
});
when(modificationsCommand.getModifications())
.thenAnswer(invocation ->
new Modifications(
currentChangesetId,
new Modified("path-1-" + currentChangesetId),
new Modified("path-2-" + currentChangesetId)
));
}
@Test
void shouldCheckAllPathsForLocks() {
PreReceiveRepositoryHookEvent event = new PreReceiveRepositoryHookEvent(new RepositoryHookEvent(context, REPOSITORY, PRE_RECEIVE));
when(changesetBuilder.getChangesets())
.thenReturn(asList(
new Changeset("1", null, null),
new Changeset("2", null, null)
));
hook.checkForLocks(event);
verify(fileLockStore).assertModifiable("path-1-1");
verify(fileLockStore).assertModifiable("path-2-1");
verify(fileLockStore).assertModifiable("path-1-2");
verify(fileLockStore).assertModifiable("path-2-2");
}
}
}

View File

@@ -0,0 +1,145 @@
/*
* 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 org.assertj.core.api.AbstractObjectAssert;
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.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.LockCommandResult;
import sonia.scm.repository.api.UnlockCommandResult;
import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore;
import java.time.Instant;
import java.util.ArrayList;
import java.util.Collection;
import java.util.Optional;
import static java.util.Optional.of;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class GitFileLockCommandTest {
private static final Repository REPOSITORY = RepositoryTestData.createHeartOfGold();
private static final Instant NOW = Instant.ofEpochSecond(-562031958);
@Mock
private GitContext context;
@Mock
private GitFileLockStoreFactory lockStoreFactory;
@Mock
private GitFileLockStore lockStore;
@InjectMocks
private GitFileLockCommand lockCommand;
@BeforeEach
void initContext() {
when(context.getRepository()).thenReturn(REPOSITORY);
}
@BeforeEach
void initStoreFactory() {
when(lockStoreFactory.create(REPOSITORY)).thenReturn(lockStore);
}
@Test
void shouldSetLockOnLockRequest() {
LockCommandRequest request = new LockCommandRequest();
request.setFile("some/file.txt");
LockCommandResult lock = lockCommand.lock(request);
assertThat(lock.isSuccessful()).isTrue();
verify(lockStore).put("some/file.txt");
}
@Test
void shouldUnlockOnUnlockRequest() {
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("some/file.txt");
UnlockCommandResult lock = lockCommand.unlock(request);
assertThat(lock.isSuccessful()).isTrue();
verify(lockStore).remove("some/file.txt", false);
}
@Test
void shouldUnlockWithForceOnUnlockRequestWithForce() {
UnlockCommandRequest request = new UnlockCommandRequest();
request.setFile("some/file.txt");
request.setForce(true);
UnlockCommandResult lock = lockCommand.unlock(request);
assertThat(lock.isSuccessful()).isTrue();
verify(lockStore).remove("some/file.txt", true);
}
@Test
void shouldGetStatus() {
when(lockStore.getLock("some/file.txt"))
.thenReturn(of(new FileLock("some/file.txt", "42", "dent", NOW)));
LockStatusCommandRequest request = new LockStatusCommandRequest();
request.setFile("some/file.txt");
Optional<FileLock> status = lockCommand.status(request);
AbstractObjectAssert<?, FileLock> statusAssert = assertThat(status).get();
statusAssert
.extracting("id")
.isEqualTo("42");
statusAssert
.extracting("path")
.isEqualTo("some/file.txt");
statusAssert
.extracting("userId")
.isEqualTo("dent");
statusAssert
.extracting("timestamp")
.isEqualTo(NOW);
}
@Test
void shouldGetAll() {
ArrayList<FileLock> existingLocks = new ArrayList<>();
when(lockStore.getAll()).thenReturn(existingLocks);
Collection<FileLock> all = lockCommand.getAll();
assertThat(all).isSameAs(existingLocks);
}
}

View File

@@ -0,0 +1,232 @@
/*
* 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 org.assertj.core.api.AbstractObjectAssert;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test;
import sonia.scm.repository.Repository;
import sonia.scm.repository.RepositoryTestData;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.store.DataStoreFactory;
import sonia.scm.store.InMemoryByteDataStoreFactory;
import java.time.Clock;
import java.time.Instant;
import java.util.Optional;
import static org.assertj.core.api.Assertions.assertThat;
import static org.junit.jupiter.api.Assertions.assertThrows;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.when;
class GitFileLockStoreFactoryTest {
private static final Instant NOW = Instant.ofEpochSecond(-562031958);
private final DataStoreFactory dataStoreFactory = new InMemoryByteDataStoreFactory();
private final Clock clock = mock(Clock.class);
private String currentUser = "dent";
private int nextId = 0;
private final GitFileLockStoreFactory gitFileLockStoreFactory =
new GitFileLockStoreFactory(dataStoreFactory, () -> "id-" + (nextId++), clock, () -> currentUser);
private final Repository repository = RepositoryTestData.createHeartOfGold();
@BeforeEach
void setClock() {
when(clock.instant()).thenReturn(NOW);
}
@Test
void shouldHaveNoLockOnStartup() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
assertThat(gitFileLockStore.getLock("some/file.txt"))
.isEmpty();
assertThat(gitFileLockStore.getAll())
.isEmpty();
assertThat(gitFileLockStore.hasLocks())
.isFalse();
}
@Test
void shouldNotFailOnRemovingNonExistingLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
gitFileLockStore.remove("some/file.txt", false);
assertThat(gitFileLockStore.getLock("some/file.txt"))
.isEmpty();
}
@Test
void shouldStoreAndRetrieveLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
FileLock createdLock = gitFileLockStore.put("some/file.txt");
Optional<FileLock> retrievedLock = gitFileLockStore.getLock("some/file.txt");
AbstractObjectAssert<?, FileLock> lockAssert = assertThat(retrievedLock)
.get();
lockAssert
.extracting("userId")
.isEqualTo("dent");
lockAssert
.extracting("id")
.isEqualTo("id-0");
lockAssert
.extracting("timestamp")
.isEqualTo(NOW);
lockAssert
.usingRecursiveComparison()
.isEqualTo(createdLock);
assertThat(gitFileLockStore.getAll())
.extracting("userId")
.containsExactly("dent");
}
@Test
void shouldRetrieveLockById() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
FileLock createdLock = gitFileLockStore.put("some/file.txt");
Optional<FileLock> retrievedLock = gitFileLockStore.getById(createdLock.getId());
assertThat(retrievedLock)
.get()
.usingRecursiveComparison()
.isEqualTo(createdLock);
}
@Test
void shouldHaveLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
gitFileLockStore.put("some/file.txt");
assertThat(gitFileLockStore.hasLocks())
.isTrue();
}
@Test
void shouldBeModifiableWithoutLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
gitFileLockStore.assertModifiable("some/file.txt");
}
@Test
void shouldBeModifiableWithOwnLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
gitFileLockStore.put("some/file.txt");
gitFileLockStore.assertModifiable("some/file.txt");
}
@Test
void shouldRemoveLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
FileLock createdLock = gitFileLockStore.put("some/file.txt");
gitFileLockStore.remove("some/file.txt", false);
assertThat(gitFileLockStore.getLock("some/file.txt"))
.isEmpty();
assertThat(gitFileLockStore.getById(createdLock.getId()))
.isEmpty();
}
@Test
void shouldRemoveLockById() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
FileLock createdLock = gitFileLockStore.put("some/file.txt");
gitFileLockStore.removeById(createdLock.getId(), false);
assertThat(gitFileLockStore.getLock("some/file.txt"))
.isEmpty();
assertThat(gitFileLockStore.getById(createdLock.getId()))
.isEmpty();
}
@Nested
class WithExistingLockFromOtherUser {
@BeforeEach
void setLock() {
currentUser = "trillian";
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
gitFileLockStore.put("some/file.txt");
currentUser = "dent";
}
@Test
void shouldNotRemoveExistingLockWithoutForce() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
assertThrows(FileLockedException.class, () -> gitFileLockStore.remove("some/file.txt", false));
assertThat(gitFileLockStore.getLock("some/file.txt"))
.get()
.extracting("userId")
.isEqualTo("trillian");
}
@Test
void shouldRemoveExistingLockWithForce() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
gitFileLockStore.remove("some/file.txt", true);
assertThat(gitFileLockStore.getLock("some/file.txt"))
.isEmpty();
}
@Test
void shouldNotBeModifiableWithOwnLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
assertThrows(FileLockedException.class, () -> gitFileLockStore.assertModifiable("some/file.txt"));
}
@Test
void shouldNotOverrideExistingLock() {
GitFileLockStoreFactory.GitFileLockStore gitFileLockStore = gitFileLockStoreFactory.create(repository);
assertThrows(FileLockedException.class, () -> gitFileLockStore.put("some/file.txt"));
assertThat(gitFileLockStore.getLock("some/file.txt"))
.get()
.extracting("userId")
.isEqualTo("trillian");
}
}
}

View File

@@ -0,0 +1,71 @@
/*
* 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.web;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
public class CapturingServletOutputStream extends ServletOutputStream {
private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@Override
public void write(int b) throws IOException {
baos.write(b);
}
@Override
public void close() throws IOException {
baos.close();
}
@Override
public String toString() {
return baos.toString();
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {
}
public JsonNode getContentAsJson() {
try {
return new ObjectMapper().readTree(toString());
} catch (JsonProcessingException e) {
throw new RuntimeException("could not unmarshal json content", e);
}
}
}

View File

@@ -32,11 +32,8 @@ import sonia.scm.config.ScmConfiguration;
import sonia.scm.repository.spi.ScmProviderHttpServlet; import sonia.scm.repository.spi.ScmProviderHttpServlet;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import javax.servlet.ServletOutputStream;
import javax.servlet.WriteListener;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.ByteArrayOutputStream;
import java.io.IOException; import java.io.IOException;
import static org.hamcrest.Matchers.containsString; import static org.hamcrest.Matchers.containsString;
@@ -133,34 +130,4 @@ public class GitPermissionFilterTest {
return request; return request;
} }
private static class CapturingServletOutputStream extends ServletOutputStream {
private final ByteArrayOutputStream baos = new ByteArrayOutputStream();
@Override
public void write(int b) throws IOException {
baos.write(b);
}
@Override
public void close() throws IOException {
baos.close();
}
@Override
public String toString() {
return baos.toString();
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setWriteListener(WriteListener writeListener) {
}
}
} }

View File

@@ -0,0 +1,535 @@
/*
* 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.web;
import com.fasterxml.jackson.databind.DeserializationFeature;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.assertj.core.api.Condition;
import org.github.sdorra.jse.ShiroExtension;
import org.github.sdorra.jse.SubjectAware;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested;
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.repository.Repository;
import sonia.scm.repository.api.FileLock;
import sonia.scm.repository.api.FileLockedException;
import sonia.scm.repository.spi.GitFileLockStoreFactory.GitFileLockStore;
import sonia.scm.user.DisplayUser;
import sonia.scm.user.User;
import sonia.scm.user.UserDisplayManager;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.time.Instant;
import static java.time.temporal.ChronoUnit.DAYS;
import static java.util.Arrays.asList;
import static java.util.Collections.singletonList;
import static java.util.Optional.empty;
import static java.util.Optional.of;
import static org.assertj.core.api.Assertions.assertThat;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.Mockito.lenient;
import static org.mockito.Mockito.never;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
@ExtendWith(ShiroExtension.class)
class LfsLockingProtocolServletTest {
private static final Repository REPOSITORY = new Repository("23", "git", "hitchhiker", "hog");
private static final Instant NOW = Instant.ofEpochSecond(-562031958);
@Mock
private GitFileLockStore lockStore;
@Mock
private UserDisplayManager userDisplayManager;
private LfsLockingProtocolServlet servlet;
@Mock
private HttpServletRequest request;
@Mock
private HttpServletResponse response;
private final CapturingServletOutputStream responseStream = new CapturingServletOutputStream();
@BeforeEach
void setUpServlet() throws IOException {
ObjectMapper mapper = new ObjectMapper();
mapper.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false);
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.configure(DeserializationFeature.ACCEPT_SINGLE_VALUE_AS_ARRAY, true);
mapper.configure(SerializationFeature.WRITE_DATES_WITH_ZONE_ID, true);
servlet = new LfsLockingProtocolServlet(REPOSITORY, lockStore, userDisplayManager, mapper, 3, 2);
lenient().when(response.getOutputStream()).thenReturn(responseStream);
}
@BeforeEach
void setUpUserDisplayManager() {
lenient().when( userDisplayManager.get("dent"))
.thenReturn(of(DisplayUser.from(new User("dent", "Arthur Dent", "irrelevant"))));
lenient().when(userDisplayManager.get("trillian"))
.thenReturn(of(DisplayUser.from(new User("trillian", "Tricia McMillan", "irrelevant"))));
}
@Nested
class WithValidLocksPath {
@BeforeEach
void mockValidPath() {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks");
}
@Test
void shouldNotBeAuthorizedToReadLocks() {
servlet.doGet(request, response);
verify(response).setStatus(403);
verify(lockStore, never()).getAll();
}
@Nested
@SubjectAware(value = "trillian", permissions = "repository:read,pull:23")
class WithReadPermission {
@Test
void shouldGetEmptyArrayForNoFileLocks() {
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks).isEmpty();
}
@Test
void shouldGetAllExistingFileLocks() {
when(lockStore.getAll())
.thenReturn(
asList(
new FileLock("some/file", "42", "dent", NOW),
new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS))
));
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks.get(0))
.is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z"));
assertThat(locks.get(1))
.is(lockNodeWith("1337", "other/file", "Tricia McMillan", "1952-04-22T00:00:42Z"));
}
@Test
void shouldGetExistingLockByPath() {
when(request.getParameter("path")).thenReturn("some/file");
when(lockStore.getLock("some/file"))
.thenReturn(of(new FileLock("some/file", "42", "dent", NOW)));
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks.get(0))
.is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z"));
}
@Test
void shouldGetEmptyListForNotExistingLockByPath() {
when(request.getParameter("path")).thenReturn("some/file");
when(lockStore.getLock("some/file"))
.thenReturn(empty());
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks).isEmpty();
}
@Test
void shouldGetExistingLockById() {
when(request.getParameter("path")).thenReturn(null);
when(request.getParameter("id")).thenReturn("42");
when(lockStore.getById("42"))
.thenReturn(of(new FileLock("some/file", "42", "dent", NOW)));
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks.get(0))
.is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z"));
}
@Test
void shouldGetEmptyListForNotExistingLockById() {
when(request.getParameter("path")).thenReturn(null);
when(request.getParameter("id")).thenReturn("42");
when(lockStore.getById("42"))
.thenReturn(empty());
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks).isEmpty();
}
@Test
void shouldUseUserIdIfUserIsUnknown() {
when(lockStore.getAll())
.thenReturn(
singletonList(
new FileLock("some/file", "42", "marvin", NOW)
));
servlet.doGet(request, response);
JsonNode locks = responseStream.getContentAsJson().get("locks");
assertThat(locks.get(0))
.is(lockNodeWith("42", "some/file", "marvin", "1952-03-11T00:00:42Z"));
}
@Test
void shouldNotBeAuthorizedToCreateNewLock() {
servlet.doPost(request, response);
verify(response).setStatus(403);
verify(lockStore, never()).put(any());
}
@Nested
class WithLimiting {
@BeforeEach
void mockManyResults() {
when(lockStore.getAll())
.thenReturn(
asList(
new FileLock("empty/file", "2", "zaphod", NOW),
new FileLock("some/file", "23", "dent", NOW),
new FileLock("any/file", "42", "marvin", NOW),
new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS))
));
}
@Test
void shouldLimitFileLocksByDefault() {
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode contentAsJson = responseStream.getContentAsJson();
JsonNode locks = contentAsJson.get("locks");
assertThat(locks).hasSize(3);
assertThat(locks.get(0).get("id").asText()).isEqualTo("2");
assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("3");
}
@Test
void shouldUseLimitFromRequest() {
lenient().doReturn("2").when(request).getParameter("limit");
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode contentAsJson = responseStream.getContentAsJson();
JsonNode locks = contentAsJson.get("locks");
assertThat(locks).hasSize(2);
assertThat(locks.get(0).get("id").asText()).isEqualTo("2");
assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("2");
}
@Test
void shouldUseCursorFromRequest() {
lenient().doReturn("3").when(request).getParameter("cursor");
servlet.doGet(request, response);
verify(response).setStatus(200);
JsonNode contentAsJson = responseStream.getContentAsJson();
JsonNode locks = contentAsJson.get("locks");
assertThat(locks).hasSize(1);
assertThat(locks.get(0).get("id").asText()).isEqualTo("1337");
assertThat(contentAsJson.get("next_cursor")).isNull();
}
}
}
@Nested
@SubjectAware(value = "trillian", permissions = "repository:read,write,pull,push:23")
class WithWritePermission {
@Test
void shouldCreateNewLock() throws IOException {
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" +
" \"path\": \"some/file.txt\"\n" +
"}"));
when(lockStore.put("some/file.txt"))
.thenReturn(new FileLock("some/file.txt", "42", "Tricia", NOW));
servlet.doPost(request, response);
verify(response).setStatus(201);
assertThat(responseStream.getContentAsJson().get("lock"))
.is(lockNodeWith("42", "some/file.txt", "Tricia", "1952-03-11T00:00:42Z"));
}
@Test
void shouldIgnoreUnknownAttributed() throws IOException {
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" +
" \"path\": \"some/file.txt\",\n" +
" \"unknown\": \"attribute\"\n" +
"}"));
when(lockStore.put("some/file.txt"))
.thenReturn(new FileLock("some/file.txt", "42", "Tricia", NOW));
servlet.doPost(request, response);
verify(response).setStatus(201);
assertThat(responseStream.getContentAsJson().get("lock"))
.is(lockNodeWith("42", "some/file.txt", "Tricia", "1952-03-11T00:00:42Z"));
}
@Test
void shouldHandleInvalidInput() throws IOException {
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" +
" \"invalidAttribute\": \"some value\"\n" +
"}"));
servlet.doPost(request, response);
verify(response).setStatus(400);
verify(lockStore, never()).put(any());
}
@Test
void shouldFailToCreateExistingLock() throws IOException {
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\n" +
" \"path\": \"some/file.txt\"\n" +
"}"));
when(lockStore.put("some/file.txt"))
.thenThrow(new FileLockedException(REPOSITORY.getNamespaceAndName(), new FileLock("some/file.txt", "42", "Tricia", NOW)));
servlet.doPost(request, response);
verify(response).setStatus(409);
JsonNode contentAsJson = responseStream.getContentAsJson();
assertThat(contentAsJson.get("lock"))
.is(lockNodeWith("42", "some/file.txt", "Tricia", "1952-03-11T00:00:42Z"));
assertThat(contentAsJson.get("message")).isNotNull();
}
@Test
void shouldGetVerifyResult() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}"));
when(lockStore.getAll())
.thenReturn(
asList(
new FileLock("some/file", "42", "dent", NOW),
new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS))
));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode ourLocks = responseStream.getContentAsJson().get("ours");
assertThat(ourLocks.get(0))
.is(lockNodeWith("1337", "other/file", "Tricia McMillan", "1952-04-22T00:00:42Z"));
JsonNode theirLocks = responseStream.getContentAsJson().get("theirs");
assertThat(theirLocks.get(0))
.is(lockNodeWith("42", "some/file", "Arthur Dent", "1952-03-11T00:00:42Z"));
}
@Test
void shouldGetVerifyResultForNoFileLocks() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}"));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode ourLocks = responseStream.getContentAsJson().get("ours");
assertThat(ourLocks).isEmpty();
JsonNode theirLocks = responseStream.getContentAsJson().get("theirs");
assertThat(theirLocks).isEmpty();
}
@Nested
class VerifyWithLimiting {
@BeforeEach
void mockManyResults() {
when(lockStore.getAll())
.thenReturn(
asList(
new FileLock("empty/file", "2", "zaphod", NOW),
new FileLock("some/file", "23", "dent", NOW),
new FileLock("any/file", "42", "marvin", NOW),
new FileLock("other/file", "1337", "trillian", NOW.plus(42, DAYS))
));
}
@Test
void shouldLimitVerifyByDefault() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}"));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode contentAsJson = responseStream.getContentAsJson();
JsonNode ourLocks = contentAsJson.get("ours");
assertThat(ourLocks).isEmpty();
JsonNode theirLocks = contentAsJson.get("theirs");
assertThat(theirLocks).hasSize(3);
assertThat(theirLocks.get(0).get("id").asText()).isEqualTo("2");
assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("3");
}
@Test
void shouldUseLimitFromRequest() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\"limit\":2}"));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode contentAsJson = responseStream.getContentAsJson();
JsonNode ourLocks = contentAsJson.get("ours");
assertThat(ourLocks).isEmpty();
JsonNode theirLocks = contentAsJson.get("theirs");
assertThat(theirLocks).hasSize(2);
assertThat(theirLocks.get(0).get("id").asText()).isEqualTo("2");
assertThat(contentAsJson.get("next_cursor").asText()).isEqualTo("2");
}
@Test
void shouldUseCursorFromRequest() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/verify");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\"cursor\":\"3\"}"));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode contentAsJson = responseStream.getContentAsJson();
JsonNode ourLocks = contentAsJson.get("ours");
assertThat(ourLocks).hasSize(1);
assertThat(ourLocks.get(0).get("id").asText()).isEqualTo("1337");
JsonNode theirLocks = contentAsJson.get("theirs");
assertThat(theirLocks).isEmpty();
assertThat(contentAsJson.get("next_cursor")).isNull();
}
}
@Test
void shouldDeleteExistingFileLock() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/42/unlock");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}"));
FileLock expectedLock = new FileLock("some/file.txt", "42", "trillian", NOW);
when(lockStore.removeById("42", false))
.thenReturn(of(expectedLock));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode deletedLock = responseStream.getContentAsJson().get("lock");
assertThat(deletedLock).is(lockNodeWith(expectedLock, "Tricia McMillan"));
}
@Test
void shouldFailToDeleteFileLockByAnotherUser() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/42/unlock");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{}"));
when(lockStore.removeById("42", false))
.thenThrow(new FileLockedException(REPOSITORY.getNamespaceAndName(), new FileLock("some/file.txt", "42", "dent", NOW)));
servlet.doPost(request, response);
verify(response).setStatus(403);
}
@Test
void shouldDeleteExistingLockWithForceFlag() throws IOException {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/locks/42/unlock");
when(request.getInputStream()).thenReturn(new BufferedServletInputStream("{\"force\":true}"));
FileLock expectedLock = new FileLock("some/file.txt", "42", "dent", NOW);
when(lockStore.removeById("42", true))
.thenReturn(of(expectedLock));
servlet.doPost(request, response);
verify(response).setStatus(200);
JsonNode deletedLock = responseStream.getContentAsJson().get("lock");
assertThat(deletedLock).is(lockNodeWith(expectedLock, "Arthur Dent"));
}
}
}
@Test
void shouldFailForIllegalPath() {
when(request.getPathInfo()).thenReturn("repo/hitchhiker/hog.git/info/lfs/other");
servlet.doGet(request, response);
verify(response).setStatus(400);
}
private Condition<? super Iterable<? extends JsonNode>> lockNodeWith(FileLock lock, String expectedName) {
return new Condition<Iterable<? extends JsonNode>>() {
@Override
public boolean matches(Iterable<? extends JsonNode> value) {
JsonNode node = (JsonNode) value;
assertThat(node.get("id").asText()).isEqualTo(lock.getId());
assertThat(node.get("path").asText()).isEqualTo(lock.getPath());
assertThat(node.get("owner").get("name").asText()).isEqualTo(expectedName);
assertThat(node.get("locked_at").asText()).isEqualTo(lock.getTimestamp().toString());
return true;
}
};
}
private Condition<? super Iterable<? extends JsonNode>> lockNodeWith(String expectedId, String expectedPath, String expectedName, String expectedTimestamp) {
return new Condition<Iterable<? extends JsonNode>>() {
@Override
public boolean matches(Iterable<? extends JsonNode> value) {
JsonNode node = (JsonNode) value;
assertThat(node.get("id").asText()).isEqualTo(expectedId);
assertThat(node.get("path").asText()).isEqualTo(expectedPath);
assertThat(node.get("owner").get("name").asText()).isEqualTo(expectedName);
assertThat(node.get("locked_at").asText()).isEqualTo(expectedTimestamp);
return true;
}
};
}
}

View File

@@ -24,17 +24,13 @@
package sonia.scm.web; package sonia.scm.web;
import com.google.common.base.Charsets;
import com.google.common.collect.Lists; import com.google.common.collect.Lists;
import org.junit.Test; import org.junit.Test;
import org.junit.runner.RunWith; import org.junit.runner.RunWith;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.MockitoJUnitRunner; import org.mockito.junit.MockitoJUnitRunner;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletRequest;
import java.io.ByteArrayInputStream;
import java.io.IOException; import java.io.IOException;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
@@ -169,32 +165,4 @@ public class WireProtocolTest {
assertTrue(commands.contains(expected)); assertTrue(commands.contains(expected));
} }
private static class BufferedServletInputStream extends ServletInputStream {
private ByteArrayInputStream input;
BufferedServletInputStream(String content) {
this.input = new ByteArrayInputStream(content.getBytes(Charsets.US_ASCII));
}
@Override
public int read() {
return input.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return false;
}
@Override
public void setReadListener(ReadListener readListener) {
}
}
} }

View File

@@ -0,0 +1,59 @@
/*
* 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.web;
import javax.servlet.ReadListener;
import javax.servlet.ServletInputStream;
import java.io.ByteArrayInputStream;
import java.nio.charset.StandardCharsets;
public class BufferedServletInputStream extends ServletInputStream {
private ByteArrayInputStream input;
BufferedServletInputStream(String content) {
this.input = new ByteArrayInputStream(content.getBytes(StandardCharsets.US_ASCII));
}
@Override
public int read() {
return input.read();
}
@Override
public boolean isFinished() {
return false;
}
@Override
public boolean isReady() {
return true;
}
@Override
public void setReadListener(ReadListener readListener) {
// not necessary for tests
}
}

View File

@@ -21,44 +21,43 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE * OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
* SOFTWARE. * SOFTWARE.
*/ */
import React from "react"; import React, { FC } from "react";
import classNames from "classnames"; import classNames from "classnames";
import { createAttributesForTesting } from "./devBuild"; import { createAttributesForTesting } from "./devBuild";
type Props = { type Props = {
title?: string; title?: string;
iconStyle: string; iconStyle?: string;
name: string; name: string;
color: string; color?: string;
className?: string; className?: string;
onClick?: (event: React.MouseEvent) => void; onClick?: (event: React.MouseEvent) => void;
onEnter?: (event: React.KeyboardEvent) => void;
testId?: string; testId?: string;
tabIndex?: number;
}; };
export default class Icon extends React.Component<Props> { const Icon: FC<Props> = ({
static defaultProps = { iconStyle = "fas",
iconStyle: "fas", color = "grey-light",
color: "grey-light" title,
}; name,
className,
onClick,
testId,
tabIndex = -1,
onEnter,
}) => {
return (
<i
onClick={onClick}
onKeyPress={(event) => event.key === "Enter" && onEnter && onEnter(event)}
title={title}
className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)}
tabIndex={tabIndex}
{...createAttributesForTesting(testId)}
/>
);
};
render() { export default Icon;
const { title, iconStyle, name, color, className, onClick, testId } = this.props;
if (title) {
return (
<i
onClick={onClick}
title={title}
className={classNames(iconStyle, "fa-fw", "fa-" + name, `has-text-${color}`, className)}
{...createAttributesForTesting(testId)}
/>
);
}
return (
<i
onClick={onClick}
className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)}
{...createAttributesForTesting(testId)}
/>
);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -44,6 +44,7 @@ export { validation, repositories };
export { default as DateFromNow } from "./DateFromNow"; export { default as DateFromNow } from "./DateFromNow";
export { default as DateShort } from "./DateShort"; export { default as DateShort } from "./DateShort";
export { default as useDateFormatter } from "./useDateFormatter";
export { default as Duration } from "./Duration"; export { default as Duration } from "./Duration";
export { default as ErrorNotification } from "./ErrorNotification"; export { default as ErrorNotification } from "./ErrorNotification";
export { default as ErrorPage } from "./ErrorPage"; export { default as ErrorPage } from "./ErrorPage";

View File

@@ -97,6 +97,7 @@ export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition<
>; >;
export type ReposSourcesTreeRowProps = { export type ReposSourcesTreeRowProps = {
repository: Repository;
file: File; file: File;
}; };

View File

@@ -22,7 +22,7 @@
* SOFTWARE. * SOFTWARE.
*/ */
import { HalRepresentation, Links } from "./hal"; import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
export type SubRepository = { export type SubRepository = {
repositoryUrl: string; repositoryUrl: string;
@@ -30,7 +30,9 @@ export type SubRepository = {
revision: string; revision: string;
}; };
export type File = { export type File = HalRepresentationWithEmbedded<{
children?: File[];
}> & {
name: string; name: string;
path: string; path: string;
directory: boolean; directory: boolean;
@@ -42,10 +44,6 @@ export type File = {
partialResult?: boolean; partialResult?: boolean;
computationAborted?: boolean; computationAborted?: boolean;
truncated?: boolean; truncated?: boolean;
_links: Links;
_embedded?: {
children?: File[] | null;
};
}; };
export type Paths = HalRepresentation & { export type Paths = HalRepresentation & {

View File

@@ -25,12 +25,11 @@
import React, { FC } from "react"; import React, { FC } from "react";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import styled from "styled-components"; import styled from "styled-components";
import { binder, ExtensionPoint } from "@scm-manager/ui-extensions"; import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
import { File, Repository } from "@scm-manager/ui-types"; import { File, Repository } from "@scm-manager/ui-types";
import FileTreeLeaf from "./FileTreeLeaf"; import FileTreeLeaf from "./FileTreeLeaf";
import TruncatedNotification from "./TruncatedNotification"; import TruncatedNotification from "./TruncatedNotification";
import { isRootPath } from "../utils/files"; import { isRootPath } from "../utils/files";
import { extensionPoints } from "@scm-manager/ui-extensions";
type Props = { type Props = {
repository: Repository; repository: Repository;
@@ -102,7 +101,7 @@ const FileTree: FC<Props> = ({ repository, directory, baseUrl, revision, fetchNe
</thead> </thead>
<tbody> <tbody>
{files.map((file: File) => ( {files.map((file: File) => (
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} /> <FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} repository={repository} />
))} ))}
</tbody> </tbody>
</table> </table>

View File

@@ -26,12 +26,13 @@ import { WithTranslation, withTranslation } from "react-i18next";
import classNames from "classnames"; import classNames from "classnames";
import styled from "styled-components"; import styled from "styled-components";
import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions"; import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
import { File } from "@scm-manager/ui-types"; import { File, Repository } from "@scm-manager/ui-types";
import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components"; import { DateFromNow, FileSize, Icon, Tooltip } from "@scm-manager/ui-components";
import FileIcon from "./FileIcon"; import FileIcon from "./FileIcon";
import FileLink from "./content/FileLink"; import FileLink from "./content/FileLink";
type Props = WithTranslation & { type Props = WithTranslation & {
repository: Repository;
file: File; file: File;
baseUrl: string; baseUrl: string;
}; };
@@ -91,12 +92,13 @@ class FileTreeLeaf extends React.Component<Props> {
}; };
render() { render() {
const { file } = this.props; const { repository, file } = this.props;
const renderFileSize = (file: File) => <FileSize bytes={file?.length ? file.length : 0} />; const renderFileSize = (file: File) => <FileSize bytes={file?.length ? file.length : 0} />;
const renderCommitDate = (file: File) => <DateFromNow date={file.commitDate} />; const renderCommitDate = (file: File) => <DateFromNow date={file.commitDate} />;
const extProps: extensionPoints.ReposSourcesTreeRowProps = { const extProps: extensionPoints.ReposSourcesTreeRowProps = {
repository,
file, file,
}; };

View File

@@ -23,19 +23,24 @@
*/ */
import React from "react"; import React from "react";
import { WithTranslation, withTranslation } from "react-i18next"; import { WithTranslation, withTranslation } from "react-i18next";
import { File } from "@scm-manager/ui-types"; import { File, Link, Repository } from "@scm-manager/ui-types";
import { DownloadButton } from "@scm-manager/ui-components"; import { DownloadButton } from "@scm-manager/ui-components";
import { ExtensionPoint } from "@scm-manager/ui-extensions";
type Props = WithTranslation & { type Props = WithTranslation & {
repository: Repository;
file: File; file: File;
}; };
class DownloadViewer extends React.Component<Props> { class DownloadViewer extends React.Component<Props> {
render() { render() {
const { t, file } = this.props; const { t, repository, file } = this.props;
return ( return (
<div className="has-text-centered"> <div className="has-text-centered">
<DownloadButton url={file._links.self.href} displayName={t("sources.content.downloadButton")} /> <ExtensionPoint name="repos.sources.content.downloadButton" props={{ repository, file }}>
<DownloadButton url={(file._links.self as Link).href} displayName={t("sources.content.downloadButton")} />
</ExtensionPoint>
</div> </div>
); );
} }

View File

@@ -80,7 +80,7 @@ const SourcesView: FC<Props> = ({ file, repository, revision }) => {
basePath, basePath,
}} }}
> >
<DownloadViewer file={file} /> <DownloadViewer repository={repository} file={file} />
</ExtensionPoint> </ExtensionPoint>
); );
} }

View File

@@ -0,0 +1,40 @@
/*
* 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.rest;
import sonia.scm.api.v2.resources.ExceptionWithContextToErrorDtoMapper;
import sonia.scm.repository.api.FileLockedException;
import javax.inject.Inject;
import javax.ws.rs.core.Response;
import javax.ws.rs.ext.Provider;
@Provider
public class FileLockedExceptionMapper extends ContextualExceptionMapper<FileLockedException> {
@Inject
public FileLockedExceptionMapper(ExceptionWithContextToErrorDtoMapper mapper) {
super(FileLockedException.class, Response.Status.CONFLICT, mapper);
}
}

View File

@@ -40,6 +40,7 @@ import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.spi.HttpScmProtocol; import sonia.scm.repository.spi.HttpScmProtocol;
import sonia.scm.security.Authentications; import sonia.scm.security.Authentications;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.web.ScmClientDetector;
import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser; import sonia.scm.web.UserAgentParser;
@@ -49,6 +50,7 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
@Singleton @Singleton
@WebElement(value = HttpProtocolServlet.PATTERN) @WebElement(value = HttpProtocolServlet.PATTERN)
@@ -63,21 +65,27 @@ public class HttpProtocolServlet extends HttpServlet {
private final NamespaceAndNameFromPathExtractor pathExtractor; private final NamespaceAndNameFromPathExtractor pathExtractor;
private final PushStateDispatcher dispatcher; private final PushStateDispatcher dispatcher;
private final UserAgentParser userAgentParser; private final UserAgentParser userAgentParser;
private final Set<ScmClientDetector> scmClientDetectors;
@Inject @Inject
public HttpProtocolServlet(ScmConfiguration configuration, RepositoryServiceFactory serviceFactory, NamespaceAndNameFromPathExtractor pathExtractor, PushStateDispatcher dispatcher, UserAgentParser userAgentParser) { public HttpProtocolServlet(ScmConfiguration configuration,
RepositoryServiceFactory serviceFactory,
NamespaceAndNameFromPathExtractor pathExtractor,
PushStateDispatcher dispatcher,
UserAgentParser userAgentParser,
Set<ScmClientDetector> scmClientDetectors) {
this.configuration = configuration; this.configuration = configuration;
this.serviceFactory = serviceFactory; this.serviceFactory = serviceFactory;
this.pathExtractor = pathExtractor; this.pathExtractor = pathExtractor;
this.dispatcher = dispatcher; this.dispatcher = dispatcher;
this.userAgentParser = userAgentParser; this.userAgentParser = userAgentParser;
this.scmClientDetectors = scmClientDetectors;
} }
@Override @Override
protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException { protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
UserAgent userAgent = userAgentParser.parse(request); UserAgent userAgent = userAgentParser.parse(request);
if (userAgent.isScmClient()) { if (isScmClient(userAgent, request)) {
String pathInfo = request.getPathInfo(); String pathInfo = request.getPathInfo();
Optional<NamespaceAndName> namespaceAndName = pathExtractor.fromUri(pathInfo); Optional<NamespaceAndName> namespaceAndName = pathExtractor.fromUri(pathInfo);
if (namespaceAndName.isPresent()) { if (namespaceAndName.isPresent()) {
@@ -92,6 +100,10 @@ public class HttpProtocolServlet extends HttpServlet {
} }
} }
private boolean isScmClient(UserAgent userAgent, HttpServletRequest request) {
return userAgent.isScmClient() || scmClientDetectors.stream().anyMatch(detector -> detector.isScmClient(request, userAgent));
}
private void service(HttpServletRequest req, HttpServletResponse resp, NamespaceAndName namespaceAndName) throws IOException, ServletException { private void service(HttpServletRequest req, HttpServletResponse resp, NamespaceAndName namespaceAndName) throws IOException, ServletException {
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) { try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
req.setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repositoryService.getRepository()); req.setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repositoryService.getRepository());

View File

@@ -421,6 +421,10 @@
"5FSV2kreE1": { "5FSV2kreE1": {
"summary": "'svn verify' fehlgeschlagen", "summary": "'svn verify' fehlgeschlagen",
"description": "Die Prüfung 'svn verify' ist für das Repository fehlgeschlagen." "description": "Die Prüfung 'svn verify' ist für das Repository fehlgeschlagen."
},
"3mSmwOtOd1": {
"displayName": "Datei gesperrt",
"description": "Die Datei oder ihre Sperre kann nicht bearbeitet werden, da eine andere Sperre existiert. Die andere Sperre kann ggf. durch eine 'forcierte' Aktion umgangen werden."
} }
}, },
"namespaceStrategies": { "namespaceStrategies": {

View File

@@ -362,6 +362,10 @@
"CISPvega31": { "CISPvega31": {
"displayName": "Illegal repository type for import", "displayName": "Illegal repository type for import",
"description": "The import is not possible for the given repository type." "description": "The import is not possible for the given repository type."
},
"3mSmwOtOd1": {
"displayName": "File locked",
"description": "The file or its lock cannot be modified, because another lock exists. This other lock may be ignored by using a 'forced' action."
} }
}, },
"healthChecksFailures": { "healthChecksFailures": {

View File

@@ -32,7 +32,6 @@ import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Nested; import org.junit.jupiter.api.Nested;
import org.junit.jupiter.api.Test; import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith; import org.junit.jupiter.api.extension.ExtendWith;
import org.mockito.InjectMocks;
import org.mockito.Mock; import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension; import org.mockito.junit.jupiter.MockitoExtension;
import sonia.scm.NotFoundException; import sonia.scm.NotFoundException;
@@ -47,6 +46,7 @@ import sonia.scm.repository.api.RepositoryService;
import sonia.scm.repository.api.RepositoryServiceFactory; import sonia.scm.repository.api.RepositoryServiceFactory;
import sonia.scm.repository.spi.HttpScmProtocol; import sonia.scm.repository.spi.HttpScmProtocol;
import sonia.scm.util.HttpUtil; import sonia.scm.util.HttpUtil;
import sonia.scm.web.ScmClientDetector;
import sonia.scm.web.UserAgent; import sonia.scm.web.UserAgent;
import sonia.scm.web.UserAgentParser; import sonia.scm.web.UserAgentParser;
@@ -55,7 +55,11 @@ import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse; import javax.servlet.http.HttpServletResponse;
import java.io.IOException; import java.io.IOException;
import java.util.Optional; import java.util.Optional;
import java.util.Set;
import static java.util.Collections.emptySet;
import static java.util.Collections.singleton;
import static org.mockito.Mockito.mock;
import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when; import static org.mockito.Mockito.when;
@@ -78,7 +82,6 @@ class HttpProtocolServletTest {
@Mock @Mock
private ScmConfiguration configuration; private ScmConfiguration configuration;
@InjectMocks
private HttpProtocolServlet servlet; private HttpProtocolServlet servlet;
@Mock @Mock
@@ -97,108 +100,72 @@ class HttpProtocolServletTest {
private HttpScmProtocol protocol; private HttpScmProtocol protocol;
@Nested @Nested
class Browser { class WithoutAdditionalScmClientDetector {
@BeforeEach @BeforeEach
void prepareMocks() { void initServlet() {
when(userAgentParser.parse(request)).thenReturn(userAgent); servlet = new HttpProtocolServlet(
when(userAgent.isScmClient()).thenReturn(false); configuration,
when(request.getRequestURI()).thenReturn("uri"); serviceFactory,
} extractor,
dispatcher,
@Test userAgentParser,
void shouldDispatchBrowserRequests() throws ServletException, IOException { emptySet()
servlet.service(request, response); );
verify(dispatcher).dispatch(request, response, "uri");
}
}
@Nested
class ScmClient {
@BeforeEach
void prepareMocks() {
when(userAgentParser.parse(request)).thenReturn(userAgent);
when(userAgent.isScmClient()).thenReturn(true);
}
@Test
void shouldHandleBadPaths() throws IOException, ServletException {
when(request.getPathInfo()).thenReturn("/illegal");
servlet.service(request, response);
verify(response).setStatus(400);
}
@Test
void shouldHandleNotExistingRepository() throws IOException, ServletException {
when(request.getPathInfo()).thenReturn("/not/exists");
NamespaceAndName repo = new NamespaceAndName("not", "exists");
when(extractor.fromUri("/not/exists")).thenReturn(Optional.of(repo));
when(serviceFactory.create(repo)).thenThrow(new NotFoundException("Test", "a"));
servlet.service(request, response);
verify(response).setStatus(404);
}
@Test
void shouldDelegateToProvider() throws IOException, ServletException {
NamespaceAndName repo = new NamespaceAndName("space", "name");
when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo));
when(serviceFactory.create(repo)).thenReturn(repositoryService);
when(request.getPathInfo()).thenReturn("/space/name");
Repository repository = RepositoryTestData.createHeartOfGold();
when(repositoryService.getRepository()).thenReturn(repository);
when(repositoryService.getProtocol(HttpScmProtocol.class)).thenReturn(protocol);
servlet.service(request, response);
verify(request).setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repository);
verify(protocol).serve(request, response, null);
verify(repositoryService).close();
} }
@Nested @Nested
class WithSubject { class Browser {
@Mock
private Subject subject;
@BeforeEach @BeforeEach
void setUpSubject() { void prepareMocks() {
ThreadContext.bind(subject); when(userAgentParser.parse(request)).thenReturn(userAgent);
} when(userAgent.isScmClient()).thenReturn(false);
when(request.getRequestURI()).thenReturn("uri");
@AfterEach
void tearDownSubject() {
ThreadContext.unbindSubject();
} }
@Test @Test
void shouldSendUnauthorizedWithCustomRealmDescription() throws IOException, ServletException { void shouldDispatchBrowserRequests() throws ServletException, IOException {
when(subject.getPrincipal()).thenReturn(SCMContext.USER_ANONYMOUS); servlet.service(request, response);
when(configuration.getRealmDescription()).thenReturn("Hitchhikers finest");
callServiceWithAuthorizationException(); verify(dispatcher).dispatch(request, response, "uri");
}
verify(response).setHeader(HttpUtil.HEADER_WWW_AUTHENTICATE, "Basic realm=\"Hitchhikers finest\""); }
verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, HttpUtil.STATUS_UNAUTHORIZED_MESSAGE);
@Nested
class ScmClient {
@BeforeEach
void prepareMocks() {
when(userAgentParser.parse(request)).thenReturn(userAgent);
when(userAgent.isScmClient()).thenReturn(true);
} }
@Test @Test
void shouldSendForbidden() throws IOException, ServletException { void shouldHandleBadPaths() throws IOException, ServletException {
callServiceWithAuthorizationException(); when(request.getPathInfo()).thenReturn("/illegal");
verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN); servlet.service(request, response);
verify(response).setStatus(400);
} }
private void callServiceWithAuthorizationException() throws IOException, ServletException { @Test
void shouldHandleNotExistingRepository() throws IOException, ServletException {
when(request.getPathInfo()).thenReturn("/not/exists");
NamespaceAndName repo = new NamespaceAndName("not", "exists");
when(extractor.fromUri("/not/exists")).thenReturn(Optional.of(repo));
when(serviceFactory.create(repo)).thenThrow(new NotFoundException("Test", "a"));
servlet.service(request, response);
verify(response).setStatus(404);
}
@Test
void shouldDelegateToProvider() throws IOException, ServletException {
NamespaceAndName repo = new NamespaceAndName("space", "name"); NamespaceAndName repo = new NamespaceAndName("space", "name");
when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo)); when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo));
when(serviceFactory.create(repo)).thenReturn(repositoryService); when(serviceFactory.create(repo)).thenReturn(repositoryService);
@@ -206,14 +173,93 @@ class HttpProtocolServletTest {
when(request.getPathInfo()).thenReturn("/space/name"); when(request.getPathInfo()).thenReturn("/space/name");
Repository repository = RepositoryTestData.createHeartOfGold(); Repository repository = RepositoryTestData.createHeartOfGold();
when(repositoryService.getRepository()).thenReturn(repository); when(repositoryService.getRepository()).thenReturn(repository);
when(repositoryService.getProtocol(HttpScmProtocol.class)).thenThrow( when(repositoryService.getProtocol(HttpScmProtocol.class)).thenReturn(protocol);
new AuthorizationException("failed")
);
servlet.service(request, response); servlet.service(request, response);
verify(request).setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repository);
verify(protocol).serve(request, response, null);
verify(repositoryService).close();
} }
@Nested
class WithSubject {
@Mock
private Subject subject;
@BeforeEach
void setUpSubject() {
ThreadContext.bind(subject);
}
@AfterEach
void tearDownSubject() {
ThreadContext.unbindSubject();
}
@Test
void shouldSendUnauthorizedWithCustomRealmDescription() throws IOException, ServletException {
when(subject.getPrincipal()).thenReturn(SCMContext.USER_ANONYMOUS);
when(configuration.getRealmDescription()).thenReturn("Hitchhikers finest");
callServiceWithAuthorizationException();
verify(response).setHeader(HttpUtil.HEADER_WWW_AUTHENTICATE, "Basic realm=\"Hitchhikers finest\"");
verify(response).sendError(HttpServletResponse.SC_UNAUTHORIZED, HttpUtil.STATUS_UNAUTHORIZED_MESSAGE);
}
@Test
void shouldSendForbidden() throws IOException, ServletException {
callServiceWithAuthorizationException();
verify(response).setStatus(HttpServletResponse.SC_FORBIDDEN);
}
private void callServiceWithAuthorizationException() throws IOException, ServletException {
NamespaceAndName repo = new NamespaceAndName("space", "name");
when(extractor.fromUri("/space/name")).thenReturn(Optional.of(repo));
when(serviceFactory.create(repo)).thenReturn(repositoryService);
when(request.getPathInfo()).thenReturn("/space/name");
Repository repository = RepositoryTestData.createHeartOfGold();
when(repositoryService.getRepository()).thenReturn(repository);
when(repositoryService.getProtocol(HttpScmProtocol.class)).thenThrow(
new AuthorizationException("failed")
);
servlet.service(request, response);
}
}
}
}
@Nested
class WithAdditionalDetector {
@Mock
private ScmClientDetector detector;
@BeforeEach
void createServlet() {
servlet = new HttpProtocolServlet(
configuration,
serviceFactory,
extractor,
dispatcher,
userAgentParser,
singleton(detector)
);
} }
@Test
void shouldConsultScmDetector() throws ServletException, IOException {
when(userAgentParser.parse(request)).thenReturn(userAgent);
when(detector.isScmClient(request, userAgent)).thenReturn(true);
servlet.service(request, response);
verify(response).setStatus(400);
}
} }
} }