LfsConnectionFactory.java

  1. /*
  2.  * Copyright (C) 2017, Markus Duft <markus.duft@ssi-schaefer.com> and others
  3.  *
  4.  * This program and the accompanying materials are made available under the
  5.  * terms of the Eclipse Distribution License v. 1.0 which is available at
  6.  * https://www.eclipse.org/org/documents/edl-v10.php.
  7.  *
  8.  * SPDX-License-Identifier: BSD-3-Clause
  9.  */
  10. package org.eclipse.jgit.lfs.internal;

  11. import static org.eclipse.jgit.util.HttpSupport.ENCODING_GZIP;
  12. import static org.eclipse.jgit.util.HttpSupport.HDR_ACCEPT;
  13. import static org.eclipse.jgit.util.HttpSupport.HDR_ACCEPT_ENCODING;
  14. import static org.eclipse.jgit.util.HttpSupport.HDR_CONTENT_TYPE;

  15. import java.io.IOException;
  16. import java.net.ProxySelector;
  17. import java.net.URISyntaxException;
  18. import java.net.URL;
  19. import java.time.LocalDateTime;
  20. import java.time.ZoneOffset;
  21. import java.time.format.DateTimeFormatter;
  22. import java.util.LinkedList;
  23. import java.util.Map;
  24. import java.util.TreeMap;

  25. import org.eclipse.jgit.annotations.NonNull;
  26. import org.eclipse.jgit.errors.CommandFailedException;
  27. import org.eclipse.jgit.lfs.LfsPointer;
  28. import org.eclipse.jgit.lfs.Protocol;
  29. import org.eclipse.jgit.lfs.errors.LfsConfigInvalidException;
  30. import org.eclipse.jgit.lib.ConfigConstants;
  31. import org.eclipse.jgit.lib.Repository;
  32. import org.eclipse.jgit.lib.StoredConfig;
  33. import org.eclipse.jgit.transport.HttpConfig;
  34. import org.eclipse.jgit.transport.HttpTransport;
  35. import org.eclipse.jgit.transport.URIish;
  36. import org.eclipse.jgit.transport.http.HttpConnection;
  37. import org.eclipse.jgit.util.HttpSupport;
  38. import org.eclipse.jgit.util.SshSupport;

  39. /**
  40.  * Provides means to get a valid LFS connection for a given repository.
  41.  */
  42. public class LfsConnectionFactory {

  43.     private static final int SSH_AUTH_TIMEOUT_SECONDS = 30;
  44.     private static final String SCHEME_HTTPS = "https"; //$NON-NLS-1$
  45.     private static final String SCHEME_SSH = "ssh"; //$NON-NLS-1$
  46.     private static final Map<String, AuthCache> sshAuthCache = new TreeMap<>();

  47.     /**
  48.      * Determine URL of LFS server by looking into config parameters lfs.url,
  49.      * lfs.[remote].url or remote.[remote].url. The LFS server URL is computed
  50.      * from remote.[remote].url by appending "/info/lfs". In case there is no
  51.      * URL configured, a SSH remote URI can be used to auto-detect the LFS URI
  52.      * by using the remote "git-lfs-authenticate" command.
  53.      *
  54.      * @param db
  55.      *            the repository to work with
  56.      * @param method
  57.      *            the method (GET,PUT,...) of the request this connection will
  58.      *            be used for
  59.      * @param purpose
  60.      *            the action, e.g. Protocol.OPERATION_DOWNLOAD
  61.      * @return the url for the lfs server. e.g.
  62.      *         "https://github.com/github/git-lfs.git/info/lfs"
  63.      * @throws IOException
  64.      */
  65.     public static HttpConnection getLfsConnection(Repository db, String method,
  66.             String purpose) throws IOException {
  67.         StoredConfig config = db.getConfig();
  68.         Map<String, String> additionalHeaders = new TreeMap<>();
  69.         String lfsUrl = getLfsUrl(db, purpose, additionalHeaders);
  70.         URL url = new URL(lfsUrl + Protocol.OBJECTS_LFS_ENDPOINT);
  71.         HttpConnection connection = HttpTransport.getConnectionFactory().create(
  72.                 url, HttpSupport.proxyFor(ProxySelector.getDefault(), url));
  73.         connection.setDoOutput(true);
  74.         if (url.getProtocol().equals(SCHEME_HTTPS)
  75.                 && !config.getBoolean(HttpConfig.HTTP,
  76.                         HttpConfig.SSL_VERIFY_KEY, true)) {
  77.             HttpSupport.disableSslVerify(connection);
  78.         }
  79.         connection.setRequestMethod(method);
  80.         connection.setRequestProperty(HDR_ACCEPT,
  81.                 Protocol.CONTENTTYPE_VND_GIT_LFS_JSON);
  82.         connection.setRequestProperty(HDR_CONTENT_TYPE,
  83.                 Protocol.CONTENTTYPE_VND_GIT_LFS_JSON);
  84.         additionalHeaders
  85.                 .forEach((k, v) -> connection.setRequestProperty(k, v));
  86.         return connection;
  87.     }

  88.     private static String getLfsUrl(Repository db, String purpose,
  89.             Map<String, String> additionalHeaders)
  90.             throws LfsConfigInvalidException {
  91.         StoredConfig config = db.getConfig();
  92.         String lfsUrl = config.getString(ConfigConstants.CONFIG_SECTION_LFS,
  93.                 null,
  94.                 ConfigConstants.CONFIG_KEY_URL);
  95.         Exception ex = null;
  96.         if (lfsUrl == null) {
  97.             String remoteUrl = null;
  98.             for (String remote : db.getRemoteNames()) {
  99.                 lfsUrl = config.getString(ConfigConstants.CONFIG_SECTION_LFS,
  100.                         remote,
  101.                         ConfigConstants.CONFIG_KEY_URL);
  102.                 // This could be done better (more precise logic), but according
  103.                 // to https://github.com/git-lfs/git-lfs/issues/1759 git-lfs
  104.                 // generally only supports 'origin' in an integrated workflow.
  105.                 if (lfsUrl == null && (remote.equals(
  106.                         org.eclipse.jgit.lib.Constants.DEFAULT_REMOTE_NAME))) {
  107.                     remoteUrl = config.getString(
  108.                             ConfigConstants.CONFIG_KEY_REMOTE, remote,
  109.                             ConfigConstants.CONFIG_KEY_URL);
  110.                     break;
  111.                 }
  112.             }
  113.             if (lfsUrl == null && remoteUrl != null) {
  114.                 try {
  115.                     lfsUrl = discoverLfsUrl(db, purpose, additionalHeaders,
  116.                             remoteUrl);
  117.                 } catch (URISyntaxException | IOException
  118.                         | CommandFailedException e) {
  119.                     ex = e;
  120.                 }
  121.             } else {
  122.                 lfsUrl = lfsUrl + Protocol.INFO_LFS_ENDPOINT;
  123.             }
  124.         }
  125.         if (lfsUrl == null) {
  126.             if (ex != null) {
  127.                 throw new LfsConfigInvalidException(
  128.                         LfsText.get().lfsNoDownloadUrl, ex);
  129.             }
  130.             throw new LfsConfigInvalidException(LfsText.get().lfsNoDownloadUrl);
  131.         }
  132.         return lfsUrl;
  133.     }

  134.     private static String discoverLfsUrl(Repository db, String purpose,
  135.             Map<String, String> additionalHeaders, String remoteUrl)
  136.             throws URISyntaxException, IOException, CommandFailedException {
  137.         URIish u = new URIish(remoteUrl);
  138.         if (u.getScheme() == null || SCHEME_SSH.equals(u.getScheme())) {
  139.             Protocol.ExpiringAction action = getSshAuthentication(db, purpose,
  140.                     remoteUrl, u);
  141.             additionalHeaders.putAll(action.header);
  142.             return action.href;
  143.         }
  144.         return remoteUrl + Protocol.INFO_LFS_ENDPOINT;
  145.     }

  146.     private static Protocol.ExpiringAction getSshAuthentication(
  147.             Repository db, String purpose, String remoteUrl, URIish u)
  148.             throws IOException, CommandFailedException {
  149.         AuthCache cached = sshAuthCache.get(remoteUrl);
  150.         Protocol.ExpiringAction action = null;
  151.         if (cached != null && cached.validUntil > System.currentTimeMillis()) {
  152.             action = cached.cachedAction;
  153.         }

  154.         if (action == null) {
  155.             // discover and authenticate; git-lfs does "ssh
  156.             // -p <port> -- <host> git-lfs-authenticate
  157.             // <project> <upload/download>"
  158.             String json = SshSupport.runSshCommand(u.setPath(""), //$NON-NLS-1$
  159.                     null, db.getFS(),
  160.                     "git-lfs-authenticate " + extractProjectName(u) + " " //$NON-NLS-1$//$NON-NLS-2$
  161.                             + purpose,
  162.                     SSH_AUTH_TIMEOUT_SECONDS);

  163.             action = Protocol.gson().fromJson(json,
  164.                     Protocol.ExpiringAction.class);

  165.             // cache the result as long as possible.
  166.             AuthCache c = new AuthCache(action);
  167.             sshAuthCache.put(remoteUrl, c);
  168.         }
  169.         return action;
  170.     }

  171.     /**
  172.      * Create a connection for the specified
  173.      * {@link org.eclipse.jgit.lfs.Protocol.Action}.
  174.      *
  175.      * @param repo
  176.      *            the repo to fetch required configuration from
  177.      * @param action
  178.      *            the action for which to create a connection
  179.      * @param method
  180.      *            the target method (GET or PUT)
  181.      * @return a connection. output mode is not set.
  182.      * @throws IOException
  183.      *             in case of any error.
  184.      */
  185.     @NonNull
  186.     public static HttpConnection getLfsContentConnection(
  187.             Repository repo, Protocol.Action action, String method)
  188.             throws IOException {
  189.         URL contentUrl = new URL(action.href);
  190.         HttpConnection contentServerConn = HttpTransport.getConnectionFactory()
  191.                 .create(contentUrl, HttpSupport
  192.                         .proxyFor(ProxySelector.getDefault(), contentUrl));
  193.         contentServerConn.setRequestMethod(method);
  194.         if (action.header != null) {
  195.             action.header.forEach(
  196.                     (k, v) -> contentServerConn.setRequestProperty(k, v));
  197.         }
  198.         if (contentUrl.getProtocol().equals(SCHEME_HTTPS)
  199.                 && !repo.getConfig().getBoolean(HttpConfig.HTTP,
  200.                         HttpConfig.SSL_VERIFY_KEY, true)) {
  201.             HttpSupport.disableSslVerify(contentServerConn);
  202.         }

  203.         contentServerConn.setRequestProperty(HDR_ACCEPT_ENCODING,
  204.                 ENCODING_GZIP);

  205.         return contentServerConn;
  206.     }

  207.     private static String extractProjectName(URIish u) {
  208.         String path = u.getPath();

  209.         // begins with a slash if the url contains a port (gerrit vs. github).
  210.         if (path.startsWith("/")) { //$NON-NLS-1$
  211.             path = path.substring(1);
  212.         }

  213.         if (path.endsWith(org.eclipse.jgit.lib.Constants.DOT_GIT)) {
  214.             return path.substring(0, path.length() - 4);
  215.         }
  216.         return path;
  217.     }

  218.     /**
  219.      * @param operation
  220.      *            the operation to perform, e.g. Protocol.OPERATION_DOWNLOAD
  221.      * @param resources
  222.      *            the LFS resources affected
  223.      * @return a request that can be serialized to JSON
  224.      */
  225.     public static Protocol.Request toRequest(String operation,
  226.             LfsPointer... resources) {
  227.         Protocol.Request req = new Protocol.Request();
  228.         req.operation = operation;
  229.         if (resources != null) {
  230.             req.objects = new LinkedList<>();
  231.             for (LfsPointer res : resources) {
  232.                 Protocol.ObjectSpec o = new Protocol.ObjectSpec();
  233.                 o.oid = res.getOid().getName();
  234.                 o.size = res.getSize();
  235.                 req.objects.add(o);
  236.             }
  237.         }
  238.         return req;
  239.     }

  240.     private static final class AuthCache {
  241.         private static final long AUTH_CACHE_EAGER_TIMEOUT = 500;

  242.         private static final DateTimeFormatter ISO_FORMAT = DateTimeFormatter
  243.                 .ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSX"); //$NON-NLS-1$

  244.         /**
  245.          * Creates a cache entry for an authentication response.
  246.          * <p>
  247.          * The timeout of the cache token is extracted from the given action. If
  248.          * no timeout can be determined, the token will be used only once.
  249.          *
  250.          * @param action
  251.          */
  252.         public AuthCache(Protocol.ExpiringAction action) {
  253.             this.cachedAction = action;
  254.             try {
  255.                 if (action.expiresIn != null && !action.expiresIn.isEmpty()) {
  256.                     this.validUntil = (System.currentTimeMillis()
  257.                             + Long.parseLong(action.expiresIn))
  258.                             - AUTH_CACHE_EAGER_TIMEOUT;
  259.                 } else if (action.expiresAt != null
  260.                         && !action.expiresAt.isEmpty()) {
  261.                     this.validUntil = LocalDateTime
  262.                             .parse(action.expiresAt, ISO_FORMAT)
  263.                             .atZone(ZoneOffset.UTC).toInstant().toEpochMilli()
  264.                             - AUTH_CACHE_EAGER_TIMEOUT;
  265.                 } else {
  266.                     this.validUntil = System.currentTimeMillis();
  267.                 }
  268.             } catch (Exception e) {
  269.                 this.validUntil = System.currentTimeMillis();
  270.             }
  271.         }

  272.         long validUntil;

  273.         Protocol.ExpiringAction cachedAction;
  274.     }

  275. }