+ * - PUT or GET + * - exactly for this repository + * - Content Type is {@link Constants#HDR_APPLICATION_OCTET_STREAM}. + * + * @return Returns {@code false} if either of the conditions does not match. Returns true if all match. + */ + private static boolean isLfsFileTransferRequest(HttpServletRequest request, String repository) { + + String regex = String.format("^%s%s/%s(\\.git)?/info/lfs/objects/[a-z0-9]{64}$", request.getContextPath(), GitServletModule.GIT_PATH, repository); + boolean pathMatches = request.getRequestURI().matches(regex); + + boolean methodMatches = request.getMethod().equals("PUT") || request.getMethod().equals("GET"); + + return pathMatches && methodMatches; + } + + /** + * Decides whether or not a request is for the LFS Batch API, + *
+ * - POST + * - exactly for this repository + * - Content Type is {@link Constants#CONTENT_TYPE_GIT_LFS_JSON}. + * + * @return Returns {@code false} if either of the conditions does not match. Returns true if all match. + */ + private static boolean isLfsBatchApiRequest(HttpServletRequest request, String repository) { + + String regex = String.format("^%s%s/%s(\\.git)?/info/lfs/objects/batch$", request.getContextPath(), GitServletModule.GIT_PATH, repository); + boolean pathMatches = request.getRequestURI().matches(regex); + + boolean methodMatches = "POST".equals(request.getMethod()); + + boolean headerContentTypeMatches = isLfsContentHeaderField(request.getContentType(), CONTENT_TYPE_GIT_LFS_JSON); + boolean headerAcceptMatches = isLfsContentHeaderField(request.getHeader("Accept"), CONTENT_TYPE_GIT_LFS_JSON); + + return pathMatches && methodMatches && headerContentTypeMatches && headerAcceptMatches; + } + + /** + * Checks whether request is of the specific content type. + * + * @param request The HTTP request header value to be examined. + * @param expectedContentType The expected content type. + * @return Returns {@code true} if the request has the expected content type. Return {@code false} otherwise. + */ + @VisibleForTesting + static boolean isLfsContentHeaderField(String request, String expectedContentType) { + + if (request == null || request.isEmpty()) { + return false; + } + + String[] parts = request.split(" "); + for (String part : parts) { + if (part.startsWith(expectedContentType)) { + + return true; + } + } + + return false; + } + + //~--- fields --------------------------------------------------------------- /** Field description */ @@ -194,6 +298,11 @@ public class ScmGitServlet extends GitServlet /** Field description */ private final RepositoryRequestListenerUtil repositoryRequestListenerUtil; - /** Field description */ + /** + * Field description + */ private final GitRepositoryViewer repositoryViewer; + + private final LfsServletFactory lfsServletFactory; + } diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsBlobStoreFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsBlobStoreFactory.java new file mode 100644 index 0000000000..eebd6b8f2b --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsBlobStoreFactory.java @@ -0,0 +1,80 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.web.lfs; + +import com.google.inject.Inject; +import com.google.inject.Singleton; +import sonia.scm.repository.Repository; +import sonia.scm.store.BlobStore; +import sonia.scm.store.BlobStoreFactory; + +/** + * Creates {@link BlobStore} objects to store lfs objects. + * + * @author Sebastian Sdorra + * @since 1.54 + */ +@Singleton +public class LfsBlobStoreFactory { + + private static final String GIT_LFS_REPOSITORY_POSTFIX = "-git-lfs"; + + private final BlobStoreFactory blobStoreFactory; + + /** + * Create a new instance. + * + * @param blobStoreFactory blob store factory + */ + @Inject + public LfsBlobStoreFactory(BlobStoreFactory blobStoreFactory) { + this.blobStoreFactory = blobStoreFactory; + } + + /** + * Provides a {@link BlobStore} corresponding to the SCM Repository. + *
+ * git-lfs repositories should generally carry the same name as their regular SCM repository counterparts. However, + * we have decided to store them under their IDs instead of their names, since the names might change and provide + * other drawbacks, as well. + *
+ * These repositories will have {@linkplain #GIT_LFS_REPOSITORY_POSTFIX} appended to their IDs. + * + * @param repository The SCM Repository to provide a LFS {@link BlobStore} for. + * + * @return blob store for the corresponding scm repository + */ + public BlobStore getLfsBlobStore(Repository repository) { + return blobStoreFactory.getBlobStore(repository.getId() + GIT_LFS_REPOSITORY_POSTFIX); + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsStoreRemoveListener.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsStoreRemoveListener.java new file mode 100644 index 0000000000..3d69dcabe6 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/LfsStoreRemoveListener.java @@ -0,0 +1,97 @@ +/** + * Copyright (c) 2010, Sebastian Sdorra + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * 1. Redistributions of source code must retain the above copyright notice, + * this list of conditions and the following disclaimer. + * 2. Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * 3. Neither the name of SCM-Manager; nor the names of its + * contributors may be used to endorse or promote products derived from this + * software without specific prior written permission. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON + * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + * + * http://bitbucket.org/sdorra/scm-manager + * + */ + + +package sonia.scm.web.lfs; + +import com.google.common.eventbus.Subscribe; +import com.google.inject.Inject; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.EagerSingleton; +import sonia.scm.HandlerEventType; +import sonia.scm.plugin.Extension; +import sonia.scm.repository.GitRepositoryHandler; +import sonia.scm.repository.Repository; +import sonia.scm.repository.RepositoryEvent; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; + +/** + * Listener which removes all lfs objects from a blob store, whenever its corresponding git repository gets deleted. + * + * @author Sebastian Sdorra + * @since 1.54 + */ +@Extension +@EagerSingleton +public class LfsStoreRemoveListener { + + private static final Logger LOG = LoggerFactory.getLogger(LfsBlobStoreFactory.class); + + private final LfsBlobStoreFactory lfsBlobStoreFactory; + + @Inject + public LfsStoreRemoveListener(LfsBlobStoreFactory lfsBlobStoreFactory) { + this.lfsBlobStoreFactory = lfsBlobStoreFactory; + } + + /** + * Remove all object from the blob store, if the event is an delete event and the repository is a git repository. + * + * @param event repository event + */ + @Subscribe + public void handleRepositoryEvent(RepositoryEvent event) { + if ( isDeleteEvent(event) && isGitRepositoryEvent(event) ) { + removeLfsStore(event.getItem()); + } + } + + private boolean isDeleteEvent(RepositoryEvent event) { + return HandlerEventType.DELETE == event.getEventType(); + } + + private boolean isGitRepositoryEvent(RepositoryEvent event) { + return event.getItem() != null + && event.getItem().getType().equals(GitRepositoryHandler.TYPE_NAME); + } + + private void removeLfsStore(Repository repository) { + LOG.debug("remove all blobs from store, because corresponding git repository {} was removed", repository.getName()); + BlobStore blobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); + for ( Blob blob : blobStore.getAll() ) { + LOG.trace("remove blob {}, because repository {} was removed", blob.getId(), repository.getName()); + blobStore.remove(blob); + } + } + +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/ScmBlobLfsRepository.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/ScmBlobLfsRepository.java new file mode 100644 index 0000000000..46a58f6f07 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/ScmBlobLfsRepository.java @@ -0,0 +1,90 @@ +package sonia.scm.web.lfs; + +import org.eclipse.jgit.lfs.lib.AnyLongObjectId; +import org.eclipse.jgit.lfs.server.LargeFileRepository; +import org.eclipse.jgit.lfs.server.Response; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; + +import java.io.IOException; + +/** + * This LargeFileRepository is used for jGit-Servlet implementation. Under the jgit LFS Servlet hood, the + * SCM-Repository API is used to implement the Repository. + * + * @since 1.54 + * Created by omilke on 03.05.2017. + */ +public class ScmBlobLfsRepository implements LargeFileRepository { + + private final BlobStore blobStore; + + /** + * This URI is used to determine the actual URI for Upload / Download. Must be full URI (or rewritable by reverse + * proxy). + */ + private final String baseUri; + + /** + * Creates a {@link ScmBlobLfsRepository} for the provided repository. + * + * @param blobStore The SCM Blobstore used for this @{@link LargeFileRepository}. + * @param baseUri This URI is used to determine the actual URI for Upload / Download. Must be full URI (or + * rewritable by reverse proxy). + */ + + public ScmBlobLfsRepository(BlobStore blobStore, String baseUri) { + + this.blobStore = blobStore; + this.baseUri = baseUri; + } + + @Override + public Response.Action getDownloadAction(AnyLongObjectId id) { + + return getAction(id); + } + + @Override + public Response.Action getUploadAction(AnyLongObjectId id, long size) { + + return getAction(id); + } + + @Override + public Response.Action getVerifyAction(AnyLongObjectId id) { + + //validation is optional. We do not support it. + return null; + } + + @Override + public long getSize(AnyLongObjectId id) throws IOException { + + //this needs to be size of what is will be written into the response of the download. Clients are likely to + // verify it. + Blob blob = this.blobStore.get(id.getName()); + if (blob == null) { + + return -1; + } else { + + return blob.getSize(); + } + + } + + /** + * Constructs the Download / Upload actions to be supplied to the client. + */ + private Response.Action getAction(AnyLongObjectId id) { + + //LFS protocol has to provide the information on where to put or get the actual content, i. e. + //the actual URI for up- and download. + + Response.Action a = new Response.Action(); + a.href = baseUri + id.getName(); + + return a; + } +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java new file mode 100644 index 0000000000..2ca10559a2 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/LfsServletFactory.java @@ -0,0 +1,76 @@ +package sonia.scm.web.lfs.servlet; + +import com.google.common.annotations.VisibleForTesting; +import org.eclipse.jgit.lfs.server.LargeFileRepository; +import org.eclipse.jgit.lfs.server.LfsProtocolServlet; +import org.eclipse.jgit.lfs.server.fs.FileLfsServlet; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.repository.Repository; +import sonia.scm.store.BlobStore; +import sonia.scm.util.HttpUtil; +import sonia.scm.web.lfs.ScmBlobLfsRepository; + +import javax.inject.Inject; +import javax.inject.Singleton; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import sonia.scm.web.lfs.LfsBlobStoreFactory; + +/** + * This factory class is a helper class to provide the {@link LfsProtocolServlet} and the {@link FileLfsServlet} + * belonging to a SCM Repository. + * + * @since 1.54 + * Created by omilke on 11.05.2017. + */ +@Singleton +public class LfsServletFactory { + + private static final Logger logger = LoggerFactory.getLogger(LfsServletFactory.class); + + private final LfsBlobStoreFactory lfsBlobStoreFactory; + + @Inject + public LfsServletFactory(LfsBlobStoreFactory lfsBlobStoreFactory) { + this.lfsBlobStoreFactory = lfsBlobStoreFactory; + } + + /** + * Builds the {@link LfsProtocolServlet} (jgit API) for a SCM Repository. + * + * @param repository The SCM Repository to build the servlet for. + * @param request The {@link HttpServletRequest} the used to access the SCM Repository. + * @return The {@link LfsProtocolServlet} to provide the LFS Batch API for a SCM Repository. + */ + public LfsProtocolServlet createProtocolServletFor(Repository repository, HttpServletRequest request) { + BlobStore blobStore = lfsBlobStoreFactory.getLfsBlobStore(repository); + String baseUri = buildBaseUri(repository, request); + + LargeFileRepository largeFileRepository = new ScmBlobLfsRepository(blobStore, baseUri); + return new ScmLfsProtocolServlet(largeFileRepository); + } + + /** + * Builds the {@link FileLfsServlet} (jgit API) for a SCM Repository. + * + * @param repository The SCM Repository to build the servlet for. + * @param request The {@link HttpServletRequest} the used to access the SCM Repository. + * @return The {@link FileLfsServlet} to provide the LFS Upload / Download API for a SCM Repository. + */ + public HttpServlet createFileLfsServletFor(Repository repository, HttpServletRequest request) { + return new ScmFileTransferServlet(lfsBlobStoreFactory.getLfsBlobStore(repository)); + } + + /** + * Build the complete URI, under which the File Transfer API for this repository will be will be reachable. + * + * @param repository The repository to build the File Transfer URI for. + * @param request The request to construct the complete URI from. + */ + @VisibleForTesting + static String buildBaseUri(Repository repository, HttpServletRequest request) { + return String.format("%s/git/%s.git/info/lfs/objects/", HttpUtil.getCompleteUrl(request), repository.getName()); + } + +} diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/ScmFileTransferServlet.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/ScmFileTransferServlet.java new file mode 100644 index 0000000000..324140b432 --- /dev/null +++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/ScmFileTransferServlet.java @@ -0,0 +1,280 @@ +package sonia.scm.web.lfs.servlet; + +import com.google.common.annotations.VisibleForTesting; +import com.google.gson.FieldNamingPolicy; +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import org.apache.http.HttpStatus; +import org.eclipse.jgit.lfs.errors.CorruptLongObjectException; +import org.eclipse.jgit.lfs.errors.InvalidLongObjectIdException; +import org.eclipse.jgit.lfs.lib.AnyLongObjectId; +import org.eclipse.jgit.lfs.lib.Constants; +import org.eclipse.jgit.lfs.lib.LongObjectId; +import org.eclipse.jgit.lfs.server.LfsProtocolServlet; +import org.eclipse.jgit.lfs.server.fs.FileLfsServlet; +import org.eclipse.jgit.lfs.server.internal.LfsServerText; +import org.eclipse.jgit.util.HttpSupport; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import sonia.scm.store.Blob; +import sonia.scm.store.BlobStore; +import sonia.scm.util.IOUtil; + +import javax.servlet.ServletException; +import javax.servlet.ServletInputStream; +import javax.servlet.ServletOutputStream; +import javax.servlet.http.HttpServlet; +import javax.servlet.http.HttpServletRequest; +import javax.servlet.http.HttpServletResponse; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.io.PrintWriter; +import java.text.MessageFormat; + +/** + * This Servlet provides the upload and download of files via git-lfs. + *
+ * This implementation is based on {@link FileLfsServlet} but adjusted to work with + * servlet-2.5 instead of servlet-3.1. + *
+ * + * @see FileLfsServlet + * @since 1.54 + * Created by omilke on 15.05.2017. + */ +public class ScmFileTransferServlet extends HttpServlet { + + private static final Logger logger = LoggerFactory.getLogger(ScmFileTransferServlet.class); + + private static final long serialVersionUID = 1L; + + /** + * Gson is used because the implementation was based on the jgit implementation. However the {@link LfsProtocolServlet} (which we do use in + * {@link ScmLfsProtocolServlet}) also uses Gson, which currently ties us to Gson anyway. + */ + private static Gson gson = createGson(); + + private final BlobStore blobStore; + + public ScmFileTransferServlet(BlobStore store) { + + this.blobStore = store; + } + + + /** + * Extracts the part after the last slash from path. + * + * @return Returns {@code null} if the part after the last slash is itself {@code null} or if its length is not 64. + */ + @VisibleForTesting + static String objectIdFromPath(String info) { + + int lastSlash = info.lastIndexOf('/'); + String potentialObjectId = info.substring(lastSlash + 1); + + if (potentialObjectId.length() != 64) { + return null; + + } else { + return potentialObjectId; + } + } + + /** + * Logs the message and provides it to the client. + * + * @param response The response + * @param status The HTTP Status Code to be provided to the client. + * @param message the message to used for server-side logging. It is also provided to the client. + */ + private static void sendErrorAndLog(HttpServletResponse response, int status, String message) throws IOException { + + logger.warn("Error occurred during git-lfs file transfer: {}", message); + + sendError(response, status, message); + } + + /** + * Logs the exception and provides only the message of the exception to the client. + * + * @param response The response + * @param status The HTTP Status Code to be provided to the client. + * @param exception An exception to used for server-side logging. + */ + private static void sendErrorAndLog(HttpServletResponse response, int status, Exception exception) throws IOException { + + logger.warn("Error occurred during git-lfs file transfer.", exception); + String message = exception.getMessage(); + + + sendError(response, status, message); + } + + private static void sendError(HttpServletResponse response, int status, String message) throws IOException { + + try (PrintWriter writer = response.getWriter()) { + + gson.toJson(new Error(message), writer); + + response.setStatus(status); + writer.flush(); + } + + response.flushBuffer(); + } + + private static Gson createGson() { + + GsonBuilder gb = new GsonBuilder().setFieldNamingPolicy(FieldNamingPolicy.LOWER_CASE_WITH_UNDERSCORES).setPrettyPrinting().disableHtmlEscaping(); + return gb.create(); + } + + /** + * Provides a blob to download. + *
+ * Actual implementation is based on org.eclipse.jgit.lfs.server.fs.ObjectDownloadListener and adjusted
+ * to non-async as we're currently on servlet-2.5.
+ *
+ * @param request servlet request
+ * @param response servlet response
+ * @throws ServletException if a servlet-specific error occurs
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ protected void doGet(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+
+ AnyLongObjectId objectId = getObjectToTransfer(request, response);
+ if (objectId == null) {
+
+ logInvalidObjectId(request.getRequestURI());
+ } else {
+
+ final String objectIdName = objectId.getName();
+ logger.trace("---- providing download for LFS-Oid: {}", objectIdName);
+
+ Blob savedBlob = blobStore.get(objectIdName);
+ if (isBlobPresent(savedBlob)) {
+
+ logger.trace("----- Object {}: providing {} bytes", objectIdName, savedBlob.getSize());
+ writeBlobIntoResponse(savedBlob, response);
+ } else {
+
+ sendErrorAndLog(response, HttpStatus.SC_NOT_FOUND, MessageFormat.format(LfsServerText.get().objectNotFound, objectIdName));
+ }
+ }
+ }
+
+ /**
+ * Receives a blob from an upload.
+ *
+ * Actual implementation is based on org.eclipse.jgit.lfs.server.fs.ObjectUploadListener and adjusted
+ * to non-async as we're currently on servlet-2.5.
+ *
+ * @param request servlet request
+ * @param response servlet response
+ * @throws ServletException if a servlet-specific error occurs
+ * @throws IOException if an I/O error occurs
+ */
+ @Override
+ protected void doPut(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException {
+
+ AnyLongObjectId objectId = getObjectToTransfer(request, response);
+ if (objectId == null) {
+
+ logInvalidObjectId(request.getRequestURI());
+ } else {
+
+ logger.trace("---- receiving upload for LFS-Oid: {}", objectId.getName());
+ readBlobFromResponse(request, response, objectId);
+ }
+ }
+
+ /**
+ * Extracts the {@link LongObjectId} from the request. Finishes the request, in case the {@link LongObjectId} cannot
+ * be extracted with an appropriate error.
+ *
+ * @throws IOException Thrown if the response could not be completed in an error case.
+ */
+ private AnyLongObjectId getObjectToTransfer(HttpServletRequest request, HttpServletResponse response) throws IOException {
+
+ String path = request.getPathInfo();
+
+ String objectIdFromPath = objectIdFromPath(path);
+ if (objectIdFromPath == null) {
+
+ //ObjectId is not retrievable from URL
+ sendErrorAndLog(response, HttpStatus.SC_UNPROCESSABLE_ENTITY, MessageFormat.format(LfsServerText.get().invalidPathInfo, path));
+ return null;
+ } else {
+ try {
+ return LongObjectId.fromString(objectIdFromPath);
+ } catch (InvalidLongObjectIdException e) {
+
+ sendErrorAndLog(response, HttpStatus.SC_UNPROCESSABLE_ENTITY, e);
+ return null;
+ }
+ }
+ }
+
+ private void logInvalidObjectId(String requestURI) {
+
+ logger.warn("---- could not extract Oid from Request. Path seems to be invalid: {}", requestURI);
+ }
+
+ private boolean isBlobPresent(Blob savedBlob) {
+
+ return savedBlob != null && savedBlob.getSize() >= 0;
+ }
+
+ private void writeBlobIntoResponse(Blob savedBlob, HttpServletResponse response) throws IOException {
+
+ try (ServletOutputStream responseOutputStream = response.getOutputStream();
+ InputStream savedBlobInputStream = savedBlob.getInputStream()) {
+
+ response.addHeader(HttpSupport.HDR_CONTENT_LENGTH, String.valueOf(savedBlob.getSize()));
+ response.setContentType(Constants.HDR_APPLICATION_OCTET_STREAM);
+
+ IOUtil.copy(savedBlobInputStream, responseOutputStream);
+ } catch (IOException ex) {
+
+ sendErrorAndLog(response, HttpStatus.SC_INTERNAL_SERVER_ERROR, ex);
+ }
+
+ }
+
+ private void readBlobFromResponse(HttpServletRequest request, HttpServletResponse response, AnyLongObjectId objectId) throws IOException {
+
+ Blob blob = blobStore.create(objectId.getName());
+ try (OutputStream blobOutputStream = blob.getOutputStream();
+ ServletInputStream requestInputStream = request.getInputStream()) {
+
+ IOUtil.copy(requestInputStream, blobOutputStream);
+ blob.commit();
+
+ response.setContentType(Constants.CONTENT_TYPE_GIT_LFS_JSON);
+ response.setStatus(HttpServletResponse.SC_OK);
+ } catch (CorruptLongObjectException ex) {
+
+ sendErrorAndLog(response, HttpStatus.SC_BAD_REQUEST, ex);
+ }
+
+
+ }
+
+ /**
+ * Used for providing an error message.
+ */
+ private static class Error {
+ String message;
+
+ Error(String m) {
+
+ this.message = m;
+ }
+ }
+
+}
+
+
diff --git a/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/ScmLfsProtocolServlet.java b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/ScmLfsProtocolServlet.java
new file mode 100644
index 0000000000..332cf12e09
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/main/java/sonia/scm/web/lfs/servlet/ScmLfsProtocolServlet.java
@@ -0,0 +1,26 @@
+package sonia.scm.web.lfs.servlet;
+
+import org.eclipse.jgit.lfs.errors.LfsException;
+import org.eclipse.jgit.lfs.server.LargeFileRepository;
+import org.eclipse.jgit.lfs.server.LfsProtocolServlet;
+
+/**
+ * Provides an implementation for the git-lfs Batch API.
+ *
+ * @since 1.54
+ * Created by omilke on 11.05.2017.
+ */
+public class ScmLfsProtocolServlet extends LfsProtocolServlet {
+
+ private final LargeFileRepository repository;
+
+ public ScmLfsProtocolServlet(LargeFileRepository largeFileRepository) {
+ this.repository = largeFileRepository;
+ }
+
+
+ @Override
+ protected LargeFileRepository getLargeFileRepository(LfsRequest request, String path) throws LfsException {
+ return repository;
+ }
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryPathMatcherTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryPathMatcherTest.java
new file mode 100644
index 0000000000..7adc4a6913
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitRepositoryPathMatcherTest.java
@@ -0,0 +1,62 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+package sonia.scm.repository;
+
+import org.junit.Test;
+import static org.junit.Assert.*;
+
+/**
+ * Unit tests for {@link GitRepositoryPathMatcher}.
+ *
+ * @author Sebastian Sdorra
+ * @since 1.54
+ */
+public class GitRepositoryPathMatcherTest {
+
+ private final GitRepositoryPathMatcher pathMatcher = new GitRepositoryPathMatcher();
+
+ @Test
+ public void testIsPathMatching() {
+ assertFalse(pathMatcher.isPathMatching(repository("my-repo"), "my-repoo"));
+ assertFalse(pathMatcher.isPathMatching(repository("my"), "my-repo"));
+ assertFalse(pathMatcher.isPathMatching(repository("my"), "my-repo/with/path"));
+
+ assertTrue(pathMatcher.isPathMatching(repository("my-repo"), "my-repo"));
+ assertTrue(pathMatcher.isPathMatching(repository("my-repo"), "my-repo.git"));
+ assertTrue(pathMatcher.isPathMatching(repository("my-repo"), "my-repo/with/path"));
+ assertTrue(pathMatcher.isPathMatching(repository("my-repo"), "my-repo.git/with/path"));
+ }
+
+ private Repository repository(String name) {
+ return new Repository(name, GitRepositoryHandler.TYPE_NAME, name);
+ }
+
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitUtilTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitUtilTest.java
index 10813098bc..1f81f401d1 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitUtilTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/GitUtilTest.java
@@ -35,15 +35,10 @@ package sonia.scm.repository;
//~--- non-JDK imports --------------------------------------------------------
-import org.eclipse.jgit.api.Git;
-import org.eclipse.jgit.api.errors.GitAPIException;
-
import org.junit.Rule;
import org.junit.Test;
import org.junit.rules.TemporaryFolder;
-import static org.hamcrest.Matchers.*;
-
import static org.junit.Assert.*;
import static org.mockito.Mockito.*;
@@ -52,8 +47,9 @@ import static org.mockito.Mockito.*;
import java.io.File;
import java.io.IOException;
+import javax.servlet.http.HttpServletRequest;
-import static org.junit.Assert.*;
+import sonia.scm.util.HttpUtil;
/**
* Unit tests for {@link GitUtil}.
@@ -125,9 +121,25 @@ public class GitUtilTest
return repo;
}
- //~--- fields ---------------------------------------------------------------
-
/** Field description */
@Rule
public TemporaryFolder temp = new TemporaryFolder();
+
+ @Test
+ public void testIsGitClient() {
+ HttpServletRequest request = mockRequestWithUserAgent("Git/2.9.3");
+ assertTrue(GitUtil.isGitClient(request));
+
+ request = mockRequestWithUserAgent("JGit/2.9.3");
+ assertTrue(GitUtil.isGitClient(request));
+
+ request = mockRequestWithUserAgent("Mozilla/5.0 (Linux; Android 4.0.4; Galaxy Nexus Build/IMM76B) ...");
+ assertFalse(GitUtil.isGitClient(request));
+ }
+
+ private HttpServletRequest mockRequestWithUserAgent(String userAgent) {
+ HttpServletRequest request = mock(HttpServletRequest.class);
+ when(request.getHeader(HttpUtil.HEADER_USERAGENT)).thenReturn(userAgent);
+ return request;
+ }
}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitRepositoryClientProvider.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitRepositoryClientProvider.java
index 2226ad04df..f545540a38 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitRepositoryClientProvider.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/repository/client/spi/GitRepositoryClientProvider.java
@@ -191,7 +191,7 @@ public class GitRepositoryClientProvider extends RepositoryClientProvider
@Override
public File getWorkingCopy() {
- return git.getRepository().getDirectory();
+ return git.getRepository().getWorkTree();
}
//~--- fields ---------------------------------------------------------------
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java
new file mode 100644
index 0000000000..df4827a9fc
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitPermissionFilterTest.java
@@ -0,0 +1,135 @@
+package sonia.scm.web;
+
+import com.google.common.base.Charsets;
+import java.io.ByteArrayOutputStream;
+import java.io.IOException;
+import javax.servlet.ServletOutputStream;
+import org.junit.Test;
+
+import javax.servlet.http.HttpServletRequest;
+import javax.servlet.http.HttpServletResponse;
+
+import static org.hamcrest.Matchers.*;
+import static org.junit.Assert.*;
+import org.junit.runner.RunWith;
+import org.mockito.Mock;
+import static org.mockito.Mockito.*;
+import org.mockito.runners.MockitoJUnitRunner;
+import sonia.scm.config.ScmConfiguration;
+import sonia.scm.repository.RepositoryProvider;
+import sonia.scm.util.HttpUtil;
+
+/**
+ * Unit tests for {@link GitPermissionFilter}.
+ *
+ * Created by omilke on 19.05.2017.
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class GitPermissionFilterTest {
+
+ @Mock
+ private RepositoryProvider repositoryProvider;
+
+ private final GitPermissionFilter permissionFilter = new GitPermissionFilter(
+ new ScmConfiguration(), repositoryProvider
+ );
+
+ @Mock
+ private HttpServletResponse response;
+
+ @Test
+ public void testIsWriteRequest() {
+ HttpServletRequest request = mockRequestWithMethodAndRequestURI("POST", "/scm/git/fanzy-project/git-receive-pack");
+ assertThat(permissionFilter.isWriteRequest(request), is(true));
+
+ request = mockRequestWithMethodAndRequestURI("GET", "/scm/git/fanzy-project/info/refs?service=git-receive-pack");
+ assertThat(permissionFilter.isWriteRequest(request), is(true));
+
+ request = mockRequestWithMethodAndRequestURI("GET", "/scm/git/fanzy-project/info/refs?service=some-other-service");
+ assertThat(permissionFilter.isWriteRequest(request), is(false));
+
+ request = mockRequestWithMethodAndRequestURI(
+ "PUT",
+ "/scm/git/git-lfs-demo.git/info/lfs/objects/8fcebeb5698230685f92028e560f8f1683ebc15ec82a620ffad5c12a3c19bdec"
+ );
+ assertThat(permissionFilter.isWriteRequest(request), is(true));
+
+ request = mockRequestWithMethodAndRequestURI(
+ "GET",
+ "/scm/git/git-lfs-demo.git/info/lfs/objects/8fcebeb5698230685f92028e560f8f1683ebc15ec82a620ffad5c12a3c19bdec"
+ );
+ assertThat(permissionFilter.isWriteRequest(request), is(false));
+
+ request = mockRequestWithMethodAndRequestURI("POST", "/scm/git/git-lfs-demo.git/info/lfs/objects/batch");
+ assertThat(permissionFilter.isWriteRequest(request), is(false));
+ }
+
+ private HttpServletRequest mockRequestWithMethodAndRequestURI(String method, String requestURI) {
+ HttpServletRequest mock = mock(HttpServletRequest.class);
+
+ when(mock.getMethod()).thenReturn(method);
+ when(mock.getRequestURI()).thenReturn(requestURI);
+ when(mock.getContextPath()).thenReturn("/scm");
+
+ return mock;
+ }
+
+ @Test
+ public void testSendNotEnoughPrivilegesErrorAsBrowser() throws IOException {
+ HttpServletRequest request = mockGitReceivePackServiceRequest();
+
+ permissionFilter.sendNotEnoughPrivilegesError(request, response);
+
+ verify(response).sendError(HttpServletResponse.SC_FORBIDDEN);
+ }
+
+ @Test
+ public void testSendNotEnoughPrivilegesErrorAsGitClient() throws IOException {
+ verifySendNotEnoughPrivilegesErrorAsGitClient("git/2.9.3");
+ }
+
+ @Test
+ public void testSendNotEnoughPrivilegesErrorAsJGitClient() throws IOException {
+ verifySendNotEnoughPrivilegesErrorAsGitClient("JGit/4.2");
+ }
+
+ private void verifySendNotEnoughPrivilegesErrorAsGitClient(String userAgent) throws IOException {
+ HttpServletRequest request = mockGitReceivePackServiceRequest();
+ when(request.getHeader(HttpUtil.HEADER_USERAGENT)).thenReturn(userAgent);
+
+ CapturingServletOutputStream stream = new CapturingServletOutputStream();
+ when(response.getOutputStream()).thenReturn(stream);
+
+ permissionFilter.sendNotEnoughPrivilegesError(request, response);
+
+ verify(response).setStatus(HttpServletResponse.SC_OK);
+ assertThat(stream.toString(), containsString("privileges"));
+ }
+
+ private HttpServletRequest mockGitReceivePackServiceRequest() {
+ HttpServletRequest request = mockRequestWithMethodAndRequestURI("GET", "/git/info/refs");
+ when(request.getParameter("service")).thenReturn("git-receive-pack");
+ 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();
+ }
+ }
+
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitRepositoryResolverTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitRepositoryResolverTest.java
new file mode 100644
index 0000000000..d41e0acafc
--- /dev/null
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitRepositoryResolverTest.java
@@ -0,0 +1,109 @@
+/**
+ * Copyright (c) 2010, Sebastian Sdorra
+ * All rights reserved.
+ *
+ * Redistribution and use in source and binary forms, with or without
+ * modification, are permitted provided that the following conditions are met:
+ *
+ * 1. Redistributions of source code must retain the above copyright notice,
+ * this list of conditions and the following disclaimer.
+ * 2. Redistributions in binary form must reproduce the above copyright notice,
+ * this list of conditions and the following disclaimer in the documentation
+ * and/or other materials provided with the distribution.
+ * 3. Neither the name of SCM-Manager; nor the names of its
+ * contributors may be used to endorse or promote products derived from this
+ * software without specific prior written permission.
+ *
+ * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS"
+ * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE
+ * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
+ * DISCLAIMED. IN NO EVENT SHALL THE REGENTS OR CONTRIBUTORS BE LIABLE FOR ANY
+ * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
+ * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES;
+ * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON
+ * ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
+ * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS
+ * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
+ *
+ * http://bitbucket.org/sdorra/scm-manager
+ *
+ */
+package sonia.scm.web;
+
+import java.io.File;
+import java.io.IOException;
+import org.junit.Test;
+import static org.junit.Assert.*;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.rules.TemporaryFolder;
+import org.junit.runner.RunWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import static org.mockito.Mockito.*;
+import org.mockito.runners.MockitoJUnitRunner;
+import sonia.scm.repository.GitConfig;
+import sonia.scm.repository.GitRepositoryHandler;
+
+/**
+ * Unit tests for {@link GitRepositoryResolver}.
+ *
+ * @author Sebastian Sdorra
+ */
+@RunWith(MockitoJUnitRunner.class)
+public class GitRepositoryResolverTest {
+
+ @Rule
+ public TemporaryFolder temporaryFolder = new TemporaryFolder();
+
+ private File parentDirectory;
+
+ @Mock
+ private GitRepositoryHandler handler;
+
+ @InjectMocks
+ private GitRepositoryResolver resolver;
+
+ @Before
+ public void setUp() throws IOException {
+ parentDirectory = temporaryFolder.newFolder();
+
+ GitConfig config = new GitConfig();
+ config.setRepositoryDirectory(parentDirectory);
+
+ when(handler.getConfig()).thenReturn(config);
+ }
+
+ @Test
+ public void testFindRepositoryWithoutDotGit() {
+ createRepositories("a", "ab");
+
+ File directory = resolver.findRepository(parentDirectory, "a");
+ assertNotNull(directory);
+ assertEquals("a", directory.getName());
+
+ directory = resolver.findRepository(parentDirectory, "ab");
+ assertNotNull(directory);
+ assertEquals("ab", directory.getName());
+ }
+
+ @Test
+ public void testFindRepositoryWithDotGit() {
+ createRepositories("a", "ab");
+
+ File directory = resolver.findRepository(parentDirectory, "a.git");
+ assertNotNull(directory);
+ assertEquals("a", directory.getName());
+
+ directory = resolver.findRepository(parentDirectory, "ab.git");
+ assertNotNull(directory);
+ assertEquals("ab", directory.getName());
+ }
+
+ private void createRepositories(String... names) {
+ for (String name : names) {
+ assertTrue(new File(parentDirectory, name).mkdirs());
+ }
+ }
+
+}
diff --git a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitUserAgentProviderTest.java b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitUserAgentProviderTest.java
index b16580c4a5..7b9fe944b5 100644
--- a/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitUserAgentProviderTest.java
+++ b/scm-plugins/scm-git-plugin/src/test/java/sonia/scm/web/GitUserAgentProviderTest.java
@@ -33,51 +33,46 @@ package sonia.scm.web;
//~--- non-JDK imports --------------------------------------------------------
-import com.google.common.base.Strings;
-
import org.junit.Test;
import static org.junit.Assert.*;
//~--- JDK imports ------------------------------------------------------------
-import java.util.Locale;
-
/**
- *
+ * Unit tests for {@link GitUserAgentProvider}.
+ *
* @author Sebastian Sdorra
This page describes the RESTful Web Service API of SCM-Manager ${project.version}.
+ ]]> +