mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-11 16:05:44 +01:00
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:
2
gradle/changelog/file_lock_for_git_lfs.yaml
Normal file
2
gradle/changelog/file_lock_for_git_lfs.yaml
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
- type: added
|
||||||
|
descripion: File lock implementation for git (lfs) ([#1838](https://github.com/scm-manager/scm-manager/pull/1838))
|
||||||
@@ -77,5 +77,10 @@ public enum Command
|
|||||||
/**
|
/**
|
||||||
* @since 2.19.0
|
* @since 2.19.0
|
||||||
*/
|
*/
|
||||||
MIRROR;
|
MIRROR,
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @since 2.26.0
|
||||||
|
*/
|
||||||
|
FILE_LOCK
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
47
scm-core/src/main/java/sonia/scm/web/ScmClientDetector.java
Normal file
47
scm-core/src/main/java/sonia/scm/web/ScmClientDetector.java
Normal 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);
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|||||||
@@ -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"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
|||||||
@@ -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.
|
||||||
*
|
*
|
||||||
|
|||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
|
||||||
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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) {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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";
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition<
|
|||||||
>;
|
>;
|
||||||
|
|
||||||
export type ReposSourcesTreeRowProps = {
|
export type ReposSourcesTreeRowProps = {
|
||||||
|
repository: Repository;
|
||||||
file: File;
|
file: 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 & {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ const SourcesView: FC<Props> = ({ file, repository, revision }) => {
|
|||||||
basePath,
|
basePath,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<DownloadViewer file={file} />
|
<DownloadViewer repository={repository} file={file} />
|
||||||
</ExtensionPoint>
|
</ExtensionPoint>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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());
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user