mirror of
https://github.com/scm-manager/scm-manager.git
synced 2025-11-12 00:15: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
|
||||
*/
|
||||
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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
|
||||
@@ -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
|
||||
*/
|
||||
@SuppressWarnings("unchecked")
|
||||
public Set<Feature> getSupportedFeatures()
|
||||
{
|
||||
return Collections.EMPTY_SET;
|
||||
return Collections.emptySet();
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -305,4 +304,11 @@ public abstract class RepositoryServiceProvider implements Closeable
|
||||
public MirrorCommand getMirrorCommand() {
|
||||
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.BUNDLE,
|
||||
Command.UNBUNDLE,
|
||||
Command.MIRROR
|
||||
Command.MIRROR,
|
||||
Command.FILE_LOCK
|
||||
);
|
||||
|
||||
protected static final Set<Feature> FEATURES = EnumSet.of(
|
||||
@@ -180,6 +181,11 @@ public class GitRepositoryServiceProvider extends RepositoryServiceProvider {
|
||||
return commandInjector.getInstance(GitMirrorCommand.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public FileLockCommand getFileLockCommand() {
|
||||
return commandInjector.getInstance(GitFileLockCommand.class);
|
||||
}
|
||||
|
||||
@Override
|
||||
public Set<Command> getSupportedCommands() {
|
||||
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.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import javax.ws.rs.core.MediaType;
|
||||
import java.io.IOException;
|
||||
import java.util.regex.Pattern;
|
||||
|
||||
@@ -65,6 +66,7 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet
|
||||
|
||||
/** the logger for ScmGitServlet */
|
||||
private static final Logger logger = getLogger(ScmGitServlet.class);
|
||||
public static final MediaType LFS_LOCKING_MEDIA_TYPE = MediaType.valueOf("application/vnd.git-lfs+json");
|
||||
|
||||
//~--- constructors ---------------------------------------------------------
|
||||
|
||||
@@ -109,6 +111,10 @@ public class ScmGitServlet extends GitServlet implements ScmProviderHttpServlet
|
||||
HttpServlet servlet = lfsServletFactory.createFileLfsServletFor(repository, request);
|
||||
logger.trace("handle lfs file transfer request");
|
||||
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)) {
|
||||
logger.trace("handle regular git request");
|
||||
// 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) {
|
||||
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 {
|
||||
if (repositoryRequestListenerUtil.callListeners(request, response, repository)) {
|
||||
servlet.service(request, response);
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
|
||||
package sonia.scm.web.lfs.servlet;
|
||||
|
||||
import com.fasterxml.jackson.databind.ObjectMapper;
|
||||
import com.google.common.annotations.VisibleForTesting;
|
||||
import org.eclipse.jgit.lfs.server.LargeFileRepository;
|
||||
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.LoggerFactory;
|
||||
import sonia.scm.repository.Repository;
|
||||
import sonia.scm.repository.spi.GitFileLockStoreFactory;
|
||||
import sonia.scm.store.BlobStore;
|
||||
import sonia.scm.user.UserDisplayManager;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.LfsLockingProtocolServlet;
|
||||
import sonia.scm.web.lfs.LfsAccessTokenFactory;
|
||||
import sonia.scm.web.lfs.LfsBlobStoreFactory;
|
||||
import sonia.scm.web.lfs.ScmBlobLfsRepository;
|
||||
@@ -56,11 +60,17 @@ public class LfsServletFactory {
|
||||
|
||||
private final LfsBlobStoreFactory lfsBlobStoreFactory;
|
||||
private final LfsAccessTokenFactory tokenFactory;
|
||||
private final GitFileLockStoreFactory lockStoreFactory;
|
||||
private final UserDisplayManager userDisplayManager;
|
||||
private final ObjectMapper objectMapper;
|
||||
|
||||
@Inject
|
||||
public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory, LfsAccessTokenFactory tokenFactory) {
|
||||
public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory, LfsAccessTokenFactory tokenFactory, GitFileLockStoreFactory lockStoreFactory, UserDisplayManager userDisplayManager, ObjectMapper objectMapper) {
|
||||
this.lfsBlobStoreFactory = lfsBlobStoreFactory;
|
||||
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));
|
||||
}
|
||||
|
||||
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.
|
||||
*
|
||||
|
||||
@@ -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.util.HttpUtil;
|
||||
|
||||
import javax.servlet.ServletOutputStream;
|
||||
import javax.servlet.WriteListener;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.ByteArrayOutputStream;
|
||||
import java.io.IOException;
|
||||
|
||||
import static org.hamcrest.Matchers.containsString;
|
||||
@@ -133,34 +130,4 @@ public class GitPermissionFilterTest {
|
||||
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;
|
||||
|
||||
import com.google.common.base.Charsets;
|
||||
import com.google.common.collect.Lists;
|
||||
import org.junit.Test;
|
||||
import org.junit.runner.RunWith;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.MockitoJUnitRunner;
|
||||
|
||||
import javax.servlet.ReadListener;
|
||||
import javax.servlet.ServletInputStream;
|
||||
import javax.servlet.http.HttpServletRequest;
|
||||
import java.io.ByteArrayInputStream;
|
||||
import java.io.IOException;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
@@ -169,32 +165,4 @@ public class WireProtocolTest {
|
||||
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
|
||||
* SOFTWARE.
|
||||
*/
|
||||
import React from "react";
|
||||
import React, { FC } from "react";
|
||||
import classNames from "classnames";
|
||||
import { createAttributesForTesting } from "./devBuild";
|
||||
|
||||
type Props = {
|
||||
title?: string;
|
||||
iconStyle: string;
|
||||
iconStyle?: string;
|
||||
name: string;
|
||||
color: string;
|
||||
color?: string;
|
||||
className?: string;
|
||||
onClick?: (event: React.MouseEvent) => void;
|
||||
onEnter?: (event: React.KeyboardEvent) => void;
|
||||
testId?: string;
|
||||
tabIndex?: number;
|
||||
};
|
||||
|
||||
export default class Icon extends React.Component<Props> {
|
||||
static defaultProps = {
|
||||
iconStyle: "fas",
|
||||
color: "grey-light"
|
||||
};
|
||||
|
||||
render() {
|
||||
const { title, iconStyle, name, color, className, onClick, testId } = this.props;
|
||||
if (title) {
|
||||
const Icon: FC<Props> = ({
|
||||
iconStyle = "fas",
|
||||
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)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<i
|
||||
onClick={onClick}
|
||||
className={classNames(iconStyle, "fa-" + name, `has-text-${color}`, className)}
|
||||
{...createAttributesForTesting(testId)}
|
||||
/>
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
export default Icon;
|
||||
|
||||
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 DateShort } from "./DateShort";
|
||||
export { default as useDateFormatter } from "./useDateFormatter";
|
||||
export { default as Duration } from "./Duration";
|
||||
export { default as ErrorNotification } from "./ErrorNotification";
|
||||
export { default as ErrorPage } from "./ErrorPage";
|
||||
|
||||
@@ -97,6 +97,7 @@ export type ReposSourcesTreeWrapperExtension = ExtensionPointDefinition<
|
||||
>;
|
||||
|
||||
export type ReposSourcesTreeRowProps = {
|
||||
repository: Repository;
|
||||
file: File;
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@
|
||||
* SOFTWARE.
|
||||
*/
|
||||
|
||||
import { HalRepresentation, Links } from "./hal";
|
||||
import { HalRepresentation, HalRepresentationWithEmbedded } from "./hal";
|
||||
|
||||
export type SubRepository = {
|
||||
repositoryUrl: string;
|
||||
@@ -30,7 +30,9 @@ export type SubRepository = {
|
||||
revision: string;
|
||||
};
|
||||
|
||||
export type File = {
|
||||
export type File = HalRepresentationWithEmbedded<{
|
||||
children?: File[];
|
||||
}> & {
|
||||
name: string;
|
||||
path: string;
|
||||
directory: boolean;
|
||||
@@ -42,10 +44,6 @@ export type File = {
|
||||
partialResult?: boolean;
|
||||
computationAborted?: boolean;
|
||||
truncated?: boolean;
|
||||
_links: Links;
|
||||
_embedded?: {
|
||||
children?: File[] | null;
|
||||
};
|
||||
};
|
||||
|
||||
export type Paths = HalRepresentation & {
|
||||
|
||||
@@ -25,12 +25,11 @@
|
||||
import React, { FC } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
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 FileTreeLeaf from "./FileTreeLeaf";
|
||||
import TruncatedNotification from "./TruncatedNotification";
|
||||
import { isRootPath } from "../utils/files";
|
||||
import { extensionPoints } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = {
|
||||
repository: Repository;
|
||||
@@ -102,7 +101,7 @@ const FileTree: FC<Props> = ({ repository, directory, baseUrl, revision, fetchNe
|
||||
</thead>
|
||||
<tbody>
|
||||
{files.map((file: File) => (
|
||||
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} />
|
||||
<FileTreeLeaf key={file.name} file={file} baseUrl={baseUrlWithRevision} repository={repository} />
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
@@ -26,12 +26,13 @@ import { WithTranslation, withTranslation } from "react-i18next";
|
||||
import classNames from "classnames";
|
||||
import styled from "styled-components";
|
||||
import { binder, ExtensionPoint, extensionPoints } from "@scm-manager/ui-extensions";
|
||||
import { File } from "@scm-manager/ui-types";
|
||||
import { DateFromNow, FileSize, Tooltip, Icon } from "@scm-manager/ui-components";
|
||||
import { File, Repository } from "@scm-manager/ui-types";
|
||||
import { DateFromNow, FileSize, Icon, Tooltip } from "@scm-manager/ui-components";
|
||||
import FileIcon from "./FileIcon";
|
||||
import FileLink from "./content/FileLink";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
repository: Repository;
|
||||
file: File;
|
||||
baseUrl: string;
|
||||
};
|
||||
@@ -91,12 +92,13 @@ class FileTreeLeaf extends React.Component<Props> {
|
||||
};
|
||||
|
||||
render() {
|
||||
const { file } = this.props;
|
||||
const { repository, file } = this.props;
|
||||
|
||||
const renderFileSize = (file: File) => <FileSize bytes={file?.length ? file.length : 0} />;
|
||||
const renderCommitDate = (file: File) => <DateFromNow date={file.commitDate} />;
|
||||
|
||||
const extProps: extensionPoints.ReposSourcesTreeRowProps = {
|
||||
repository,
|
||||
file,
|
||||
};
|
||||
|
||||
|
||||
@@ -23,19 +23,24 @@
|
||||
*/
|
||||
import React from "react";
|
||||
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 { ExtensionPoint } from "@scm-manager/ui-extensions";
|
||||
|
||||
type Props = WithTranslation & {
|
||||
repository: Repository;
|
||||
file: File;
|
||||
};
|
||||
|
||||
class DownloadViewer extends React.Component<Props> {
|
||||
render() {
|
||||
const { t, file } = this.props;
|
||||
const { t, repository, file } = this.props;
|
||||
|
||||
return (
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -80,7 +80,7 @@ const SourcesView: FC<Props> = ({ file, repository, revision }) => {
|
||||
basePath,
|
||||
}}
|
||||
>
|
||||
<DownloadViewer file={file} />
|
||||
<DownloadViewer repository={repository} file={file} />
|
||||
</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.security.Authentications;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.ScmClientDetector;
|
||||
import sonia.scm.web.UserAgent;
|
||||
import sonia.scm.web.UserAgentParser;
|
||||
|
||||
@@ -49,6 +50,7 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
import java.util.Optional;
|
||||
import java.util.Set;
|
||||
|
||||
@Singleton
|
||||
@WebElement(value = HttpProtocolServlet.PATTERN)
|
||||
@@ -63,21 +65,27 @@ public class HttpProtocolServlet extends HttpServlet {
|
||||
private final NamespaceAndNameFromPathExtractor pathExtractor;
|
||||
private final PushStateDispatcher dispatcher;
|
||||
private final UserAgentParser userAgentParser;
|
||||
|
||||
private final Set<ScmClientDetector> scmClientDetectors;
|
||||
|
||||
@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.serviceFactory = serviceFactory;
|
||||
this.pathExtractor = pathExtractor;
|
||||
this.dispatcher = dispatcher;
|
||||
this.userAgentParser = userAgentParser;
|
||||
this.scmClientDetectors = scmClientDetectors;
|
||||
}
|
||||
|
||||
@Override
|
||||
protected void service(HttpServletRequest request, HttpServletResponse response) throws IOException, ServletException {
|
||||
UserAgent userAgent = userAgentParser.parse(request);
|
||||
if (userAgent.isScmClient()) {
|
||||
if (isScmClient(userAgent, request)) {
|
||||
String pathInfo = request.getPathInfo();
|
||||
Optional<NamespaceAndName> namespaceAndName = pathExtractor.fromUri(pathInfo);
|
||||
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 {
|
||||
try (RepositoryService repositoryService = serviceFactory.create(namespaceAndName)) {
|
||||
req.setAttribute(DefaultRepositoryProvider.ATTRIBUTE_NAME, repositoryService.getRepository());
|
||||
|
||||
@@ -421,6 +421,10 @@
|
||||
"5FSV2kreE1": {
|
||||
"summary": "'svn verify' 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": {
|
||||
|
||||
@@ -362,6 +362,10 @@
|
||||
"CISPvega31": {
|
||||
"displayName": "Illegal repository type for import",
|
||||
"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": {
|
||||
|
||||
@@ -32,7 +32,6 @@ 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.InjectMocks;
|
||||
import org.mockito.Mock;
|
||||
import org.mockito.junit.jupiter.MockitoExtension;
|
||||
import sonia.scm.NotFoundException;
|
||||
@@ -47,6 +46,7 @@ import sonia.scm.repository.api.RepositoryService;
|
||||
import sonia.scm.repository.api.RepositoryServiceFactory;
|
||||
import sonia.scm.repository.spi.HttpScmProtocol;
|
||||
import sonia.scm.util.HttpUtil;
|
||||
import sonia.scm.web.ScmClientDetector;
|
||||
import sonia.scm.web.UserAgent;
|
||||
import sonia.scm.web.UserAgentParser;
|
||||
|
||||
@@ -55,7 +55,11 @@ import javax.servlet.http.HttpServletRequest;
|
||||
import javax.servlet.http.HttpServletResponse;
|
||||
import java.io.IOException;
|
||||
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.when;
|
||||
|
||||
@@ -78,7 +82,6 @@ class HttpProtocolServletTest {
|
||||
@Mock
|
||||
private ScmConfiguration configuration;
|
||||
|
||||
@InjectMocks
|
||||
private HttpProtocolServlet servlet;
|
||||
|
||||
@Mock
|
||||
@@ -96,6 +99,21 @@ class HttpProtocolServletTest {
|
||||
@Mock
|
||||
private HttpScmProtocol protocol;
|
||||
|
||||
@Nested
|
||||
class WithoutAdditionalScmClientDetector {
|
||||
|
||||
@BeforeEach
|
||||
void initServlet() {
|
||||
servlet = new HttpProtocolServlet(
|
||||
configuration,
|
||||
serviceFactory,
|
||||
extractor,
|
||||
dispatcher,
|
||||
userAgentParser,
|
||||
emptySet()
|
||||
);
|
||||
}
|
||||
|
||||
@Nested
|
||||
class Browser {
|
||||
|
||||
@@ -212,8 +230,36 @@ class HttpProtocolServletTest {
|
||||
|
||||
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