diff --git a/src/main/scala/nl/zolotko/sbt/gitlab/GitlabPlugin.scala b/src/main/scala/nl/zolotko/sbt/gitlab/GitlabPlugin.scala index 8331feb..2069ec8 100644 --- a/src/main/scala/nl/zolotko/sbt/gitlab/GitlabPlugin.scala +++ b/src/main/scala/nl/zolotko/sbt/gitlab/GitlabPlugin.scala @@ -1,14 +1,13 @@ package nl.zolotko.sbt.gitlab -import java.net.URLEncoder import lmcoursier.CoursierConfiguration import lmcoursier.definitions.Authentication -import okhttp3.OkHttpClient -import org.apache.ivy.util.url.{URLHandler, URLHandlerDispatcher, URLHandlerRegistry} -import sbt.Keys._ -import sbt.internal.CustomHttp -import sbt.internal.librarymanagement.ivyint.GigahorseUrlHandler -import sbt.{Credentials, Def, settingKey, _} +import org.apache.ivy.util.url.{GitlabURLHandler, URLHandler, URLHandlerDispatcher, URLHandlerRegistry} +import sbt.* +import sbt.Keys.* + +import java.net.URLEncoder + object GitlabPlugin extends AutoPlugin { // This plugin will load automatically @@ -61,29 +60,7 @@ object GitlabPlugin extends AutoPlugin { val gitlabRepositories = settingKey[Seq[GitlabRepository]]("GitLab repositories for automatically managed dependencies.") } - import autoImport._ - - private def headerEnrichingClientBuilder( - existingBuilder: OkHttpClient.Builder, - domain: String, - credentials: Seq[GitlabCredentials], - logger: Logger - ): OkHttpClient.Builder = - credentials.find(_.domain == domain) match { - case Some(credentials) => - logger.debug("building a custom HTTP client for GitLab") - existingBuilder - .addNetworkInterceptor(HeaderInjector(credentials, domain, logger)) - case None => - existingBuilder - } - - private def dispatcherForClient(client: OkHttpClient): URLHandlerDispatcher = - new URLHandlerDispatcher { - Seq("http", "https").foreach(super.setDownloader(_, new GigahorseUrlHandler(client))) - - override def setDownloader(protocol: String, downloader: URLHandler): Unit = () - } + import autoImport.* private lazy val gitlabHeaderAuthHandler = taskKey[Unit]("perform auth using header credentials") @@ -122,16 +99,15 @@ object GitlabPlugin extends AutoPlugin { .get("CI_JOB_TOKEN") .map(GitlabCredentials(gitlabDomain.value, "Job-Token", _)), gitlabHeaderAuthHandler := { - val credentials = allGitlabCredentials.value - val logger = streams.value.log - val client = headerEnrichingClientBuilder( - CustomHttp.okhttpClientBuilder.value, - gitlabDomain.value, - credentials, - logger - ).build() - val dispatcher = dispatcherForClient(client) - URLHandlerRegistry.setDefault(dispatcher) + val downloader = new GitlabURLHandler(allGitlabCredentials.value) + + URLHandlerRegistry.setDefault( + new URLHandlerDispatcher { + Seq("http", "https").foreach(super.setDownloader(_, downloader)) + + override def setDownloader(protocol: String, downloader: URLHandler): Unit = () + } + ) }, update := update.dependsOn(gitlabHeaderAuthHandler).value, updateClassifiers := updateClassifiers.dependsOn(gitlabHeaderAuthHandler).value, diff --git a/src/main/scala/nl/zolotko/sbt/gitlab/HeaderInjector.scala b/src/main/scala/nl/zolotko/sbt/gitlab/HeaderInjector.scala deleted file mode 100644 index 32a8aec..0000000 --- a/src/main/scala/nl/zolotko/sbt/gitlab/HeaderInjector.scala +++ /dev/null @@ -1,24 +0,0 @@ -package nl.zolotko.sbt.gitlab - -import okhttp3.{Interceptor, Response} -import sbt.util.Logger - -case class HeaderInjector( - credentials: GitlabCredentials, - hostMatch: String, - logger: Logger -) extends Interceptor { - override def intercept(chain: Interceptor.Chain): Response = { - val oldReq = chain.request() - chain.proceed( - if (oldReq.url.host.contains(hostMatch) && Option(oldReq.headers.get(credentials.key)).isEmpty) { - logger.debug(s"injecting gitlab token for $oldReq") - val newReq = - oldReq.newBuilder().addHeader(credentials.key, credentials.value).build() - logger.debug(newReq.toString) - logger.debug(newReq.headers().toString) - newReq - } else oldReq - ) - } -} diff --git a/src/main/scala/org/apache/ivy/util/url/GitlabURLHandler.java b/src/main/scala/org/apache/ivy/util/url/GitlabURLHandler.java new file mode 100644 index 0000000..70fe2f0 --- /dev/null +++ b/src/main/scala/org/apache/ivy/util/url/GitlabURLHandler.java @@ -0,0 +1,428 @@ +package org.apache.ivy.util.url; +/* + * Licensed to the Apache Software Foundation (ASF) under one or more + * contributor license agreements. See the NOTICE file distributed with + * this work for additional information regarding copyright ownership. + * The ASF licenses this file to You under the Apache License, Version 2.0 + * (the "License"); you may not use this file except in compliance with + * the License. You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +import java.io.ByteArrayInputStream; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileInputStream; +import java.io.IOException; +import java.io.InputStream; +import java.io.OutputStream; +import java.net.HttpURLConnection; +import java.net.URL; +import java.net.URLConnection; +import java.net.UnknownHostException; +import java.util.List; +import java.util.Map; +import java.net.URLDecoder; + +import nl.zolotko.sbt.gitlab.GitlabCredentials; +import org.apache.ivy.Ivy; +import org.apache.ivy.util.CopyProgressListener; +import org.apache.ivy.util.FileUtil; +import org.apache.ivy.util.Message; +import scala.collection.Seq; + +// copied from org.apache.ivy.util.url.BasicURLHandler +public class GitlabURLHandler extends AbstractURLHandler { + private final Seq gitlabCredentials; + + public GitlabURLHandler(Seq gitlabCredentials) { + this.gitlabCredentials = gitlabCredentials; + } + + private void setGitlabHeader(HttpURLConnection conn) { + gitlabCredentials + .find((GitlabCredentials c) -> { + var host = conn.getURL().getHost(); + return host.equals(c.domain()) || host.endsWith("." + c.domain()); + }) + .foreach(c -> { + conn.setRequestProperty(c.key(), c.value()); + return new Object(); + }); + } + + private static final int BUFFER_SIZE = 64 * 1024; + private static final int ERROR_BODY_TRUNCATE_LEN = 512; + + private static final class HttpStatus { + static final int SC_OK = 200; + + static final int SC_PROXY_AUTHENTICATION_REQUIRED = 407; + + private HttpStatus() { + } + } + + public URLInfo getURLInfo(URL url) { + return getURLInfo(url, 0); + } + + public URLInfo getURLInfo(URL url, int timeout) { + // Install the IvyAuthenticator + if ("http".equals(url.getProtocol()) || "https".equals(url.getProtocol())) { + IvyAuthenticator.install(); + } + + URLConnection con = null; + try { + url = normalizeToURL(url); + con = url.openConnection(); + con.setRequestProperty("User-Agent", "Apache Ivy/" + Ivy.getIvyVersion()); + if (con instanceof HttpURLConnection) { + HttpURLConnection httpCon = (HttpURLConnection) con; + setGitlabHeader(httpCon); + + if (getRequestMethod() == URLHandler.REQUEST_METHOD_HEAD) { + httpCon.setRequestMethod("HEAD"); + } + if (checkStatusCode(url, httpCon)) { + String bodyCharset = getCharSetFromContentType(con.getContentType()); + return new URLInfo(true, httpCon.getContentLength(), con.getLastModified(), bodyCharset); + } + } else { + int contentLength = con.getContentLength(); + if (contentLength <= 0) { + return UNAVAILABLE; + } else { + // TODO: not HTTP... maybe we *don't* want to default to ISO-8559-1 here? + String bodyCharset = getCharSetFromContentType(con.getContentType()); + return new URLInfo(true, contentLength, con.getLastModified(), bodyCharset); + } + } + } catch (UnknownHostException e) { + Message.warn("Host " + e.getMessage() + " not found. url=" + url); + Message.info("You probably access the destination server through " + + "a proxy server that is not well configured."); + } catch (IOException e) { + Message.error("Server access Error: " + e.getMessage() + " url=" + url); + } finally { + disconnect(con); + } + return UNAVAILABLE; + } + + /** + * Extract the charset from the Content-Type header string, or default to ISO-8859-1 as per + * rfc2616-sec3.html#sec3.7.1 . + * + * @param contentType the Content-Type header string + * @return the charset as specified in the content type, or ISO-8859-1 if unspecified. + */ + public static String getCharSetFromContentType(String contentType) { + + String charSet = null; + + if (contentType != null) { + String[] elements = contentType.split(";"); + for (int i = 0; i < elements.length; i++) { + String element = elements[i].trim(); + if (element.toLowerCase().startsWith("charset=")) { + charSet = element.substring("charset=".length()); + } + } + } + + if (charSet == null || charSet.length() == 0) { + // default to ISO-8859-1 as per rfc2616-sec3.html#sec3.7.1 + charSet = "ISO-8859-1"; + } + + return charSet; + } + + private boolean checkStatusCode(URL url, HttpURLConnection con) throws IOException { + int status = con.getResponseCode(); + if (status == HttpStatus.SC_OK) { + return true; + } + + // IVY-1328: some servers return a 204 on a HEAD request + if ("HEAD".equals(con.getRequestMethod()) && (status == 204)) { + return true; + } + + Message.debug("HTTP response status: " + status + " url=" + url); + if (status == HttpStatus.SC_PROXY_AUTHENTICATION_REQUIRED) { + Message.warn("Your proxy requires authentication."); + } else if (String.valueOf(status).startsWith("4")) { + Message.verbose("CLIENT ERROR: " + con.getResponseMessage() + " url=" + url); + } else if (String.valueOf(status).startsWith("5")) { + Message.error("SERVER ERROR: " + con.getResponseMessage() + " url=" + url); + } + return false; + } + + public InputStream openStream(URL url) throws IOException { + // Install the IvyAuthenticator + if ("http".equals(url.getProtocol()) || "https".equals(url.getProtocol())) { + IvyAuthenticator.install(); + } + + URLConnection conn = null; + try { + url = normalizeToURL(url); + conn = url.openConnection(); + conn.setRequestProperty("User-Agent", "Apache Ivy/" + Ivy.getIvyVersion()); + conn.setRequestProperty("Accept-Encoding", "gzip,deflate"); + conn.setRequestProperty("Accept", "application/octet-stream, application/json, application/xml, */*"); + if (conn instanceof HttpURLConnection) { + HttpURLConnection httpCon = (HttpURLConnection) conn; + setGitlabHeader(httpCon); + + if (!checkStatusCode(url, httpCon)) { + throw new IOException( + "The HTTP response code for " + url + " did not indicate a success." + + " See log for more detail."); + } + } + InputStream inStream = getDecodingInputStream(conn.getContentEncoding(), + conn.getInputStream()); + ByteArrayOutputStream outStream = new ByteArrayOutputStream(); + + byte[] buffer = new byte[BUFFER_SIZE]; + int len; + while ((len = inStream.read(buffer)) > 0) { + outStream.write(buffer, 0, len); + } + return new ByteArrayInputStream(outStream.toByteArray()); + } finally { + disconnect(conn); + } + } + + public void download(URL src, File dest, CopyProgressListener l) throws IOException { + // Install the IvyAuthenticator + if ("http".equals(src.getProtocol()) || "https".equals(src.getProtocol())) { + IvyAuthenticator.install(); + } + + URLConnection srcConn = null; + try { + src = normalizeToURL(src); + srcConn = src.openConnection(); + srcConn.setRequestProperty("User-Agent", "Apache Ivy/" + Ivy.getIvyVersion()); + srcConn.setRequestProperty("Accept-Encoding", "gzip,deflate"); + srcConn.setRequestProperty("Accept", "application/octet-stream, application/json, application/xml, */*"); + if (srcConn instanceof HttpURLConnection) { + HttpURLConnection httpCon = (HttpURLConnection) srcConn; + setGitlabHeader(httpCon); + int status = httpCon.getResponseCode(); + if (status == 302 || status == 301) { + String location = httpCon.getHeaderField("Location"); + location = URLDecoder.decode(location, "UTF-8"); + URL next = new URL(location); + download(next, dest, l); + disconnect(srcConn); + return; + } else { + if (!checkStatusCode(src, httpCon)) { + throw new IOException( + "The HTTP response code for " + src + " did not indicate a success." + + " See log for more detail."); + } + } + } + + // do the download + InputStream inStream = getDecodingInputStream(srcConn.getContentEncoding(), + srcConn.getInputStream()); + FileUtil.copy(inStream, dest, l); + + // check content length only if content was not encoded + if (srcConn.getContentEncoding() == null) { + int contentLength = srcConn.getContentLength(); + if (contentLength != -1 && dest.length() != contentLength) { + dest.delete(); + throw new IOException( + "Downloaded file size doesn't match expected Content Length for " + src + + ". Please retry."); + } + } + + // update modification date + long lastModified = srcConn.getLastModified(); + if (lastModified > 0) { + dest.setLastModified(lastModified); + } + } finally { + disconnect(srcConn); + } + } + + public void upload(File source, URL dest, CopyProgressListener l) throws IOException { + if (!"http".equals(dest.getProtocol()) && !"https".equals(dest.getProtocol())) { + throw new UnsupportedOperationException( + "URL repository only support HTTP PUT at the moment"); + } + + // Install the IvyAuthenticator + IvyAuthenticator.install(); + + HttpURLConnection conn = null; + try { + dest = normalizeToURL(dest); + conn = (HttpURLConnection) dest.openConnection(); + setGitlabHeader(conn); + conn.setDoOutput(true); + conn.setRequestMethod("PUT"); + conn.setRequestProperty("User-Agent", "Apache Ivy/" + Ivy.getIvyVersion()); + conn.setRequestProperty("Accept", "application/octet-stream, application/json, application/xml, */*"); + conn.setRequestProperty("Content-type", "application/octet-stream"); + conn.setRequestProperty("Content-length", Long.toString(source.length())); + conn.setInstanceFollowRedirects(true); + + Message.debug("Request Headers:" + getHeadersAsDebugString(conn.getRequestProperties())); + InputStream in = new FileInputStream(source); + try { + OutputStream os = conn.getOutputStream(); + FileUtil.copy(in, os, l); + } finally { + try { + in.close(); + } catch (IOException e) { + /* ignored */ + } + } + + // initiate the connection + int responseCode = conn.getResponseCode(); + + String extra = ""; + InputStream errorStream = conn.getErrorStream(); + InputStream responseStream = conn.getInputStream(); + if (errorStream != null) { + extra = "; Response Body: " + readTruncated(errorStream, ERROR_BODY_TRUNCATE_LEN, + conn.getContentType(), conn.getContentEncoding()); + } else if (responseStream != null) { + InputStream decodingStream = getDecodingInputStream(conn.getContentEncoding(), responseStream); + extra = "; Response Body: " + readTruncated(responseStream, ERROR_BODY_TRUNCATE_LEN, + conn.getContentType(), conn.getContentEncoding()); + } + Message.debug("Response Headers:" + getHeadersAsDebugString(conn.getHeaderFields())); + validatePutStatusCode(dest, responseCode, conn.getResponseMessage() + extra); + } finally { + disconnect(conn); + } + } + + private String getHeadersAsDebugString(Map> headers) throws IOException { + StringBuilder builder = new StringBuilder(""); + + if (headers != null) { + for (Map.Entry> header : headers.entrySet()) { + String key = header.getKey(); + if (key != null) { + builder.append(header.getKey()); + builder.append(": "); + } + builder.append(String.join("\n ", header.getValue())); + builder.append("\n"); + } + } + return builder.toString(); + } + + private String readTruncated(InputStream is, int maxLen, String contentType, + String contentEncoding) throws IOException { + + InputStream decodingStream = getDecodingInputStream(contentEncoding, is); + String charSet = getCharSetFromContentType(contentType); + ByteArrayOutputStream os = new ByteArrayOutputStream(maxLen); + try { + int count = 0; + int b = decodingStream.read(); + while (count < maxLen && b >= 0) { + os.write(b); + count += 1; + b = decodingStream.read(); + } + return new String(os.toByteArray(), charSet); + } finally { + try { + is.close(); + } catch (IOException e) { + /* ignored */ + } + } + } + + private void disconnect(URLConnection con) { + if (con instanceof HttpURLConnection) { + if (!"HEAD".equals(((HttpURLConnection) con).getRequestMethod())) { + // We must read the response body before disconnecting! + // Cfr. http://java.sun.com/j2se/1.5.0/docs/guide/net/http-keepalive.html + // [quote]Do not abandon a connection by ignoring the response body. Doing + // so may results in idle TCP connections.[/quote] + readResponseBody((HttpURLConnection) con); + } + + ((HttpURLConnection) con).disconnect(); + } else if (con != null) { + try { + con.getInputStream().close(); + } catch (IOException e) { + // ignored + } + } + } + + /** + * Read and ignore the response body. + */ + private void readResponseBody(HttpURLConnection conn) { + byte[] buffer = new byte[BUFFER_SIZE]; + + InputStream inStream = null; + try { + inStream = conn.getInputStream(); + while (inStream.read(buffer) > 0) { + //Skip content + } + } catch (IOException e) { + // ignore + } finally { + if (inStream != null) { + try { + inStream.close(); + } catch (IOException e) { + // ignore + } + } + } + + InputStream errStream = conn.getErrorStream(); + if (errStream != null) { + try { + while (errStream.read(buffer) > 0) { + //Skip content + } + } catch (IOException e) { + // ignore + } finally { + try { + errStream.close(); + } catch (IOException e) { + // ignore + } + } + } + } +} \ No newline at end of file