Java tutorial
/* Copyright 2013 Lyor Goldstein * * Licensed 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. */ package net.community.chest.gitcloud.facade.frontend.git; import java.io.IOException; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; import java.net.MalformedURLException; import java.net.URI; import java.net.URISyntaxException; import java.nio.charset.Charset; import java.security.Principal; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Map; import java.util.NoSuchElementException; import java.util.Set; import java.util.SortedSet; import java.util.TreeMap; import java.util.TreeSet; import java.util.logging.Level; import javax.inject.Inject; import javax.management.JMException; import javax.management.MBeanInfo; import javax.management.MBeanServer; import javax.management.ObjectName; import javax.servlet.RequestDispatcher; import javax.servlet.ServletContext; import javax.servlet.ServletException; import javax.servlet.http.HttpServletRequest; import javax.servlet.http.HttpServletResponse; import net.community.chest.gitcloud.facade.ServletUtils; import org.apache.commons.beanutils.AbstractSimpleJavaBean; import org.apache.commons.codec.binary.Base64; import org.apache.commons.collections15.SetUtils; import org.apache.commons.io.HexDumpOutputStream; import org.apache.commons.io.input.TeeInputStream; import org.apache.commons.io.output.LineLevelAppender; import org.apache.commons.io.output.TeeOutputStream; import org.apache.commons.lang3.ArrayUtils; import org.apache.commons.lang3.ExtendedStringUtils; import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.Validate; import org.apache.commons.logging.ExtendedLogUtils; import org.apache.http.Header; import org.apache.http.HttpEntity; import org.apache.http.HttpMessage; import org.apache.http.HttpRequest; import org.apache.http.HttpResponse; import org.apache.http.ProtocolException; import org.apache.http.StatusLine; import org.apache.http.auth.AUTH; import org.apache.http.client.RedirectStrategy; import org.apache.http.client.config.AuthSchemes; import org.apache.http.client.methods.CloseableHttpResponse; import org.apache.http.client.methods.HttpEntityEnclosingRequestBase; import org.apache.http.client.methods.HttpGet; import org.apache.http.client.methods.HttpPost; import org.apache.http.client.methods.HttpRequestBase; import org.apache.http.client.methods.HttpUriRequest; import org.apache.http.conn.HttpClientConnectionManager; import org.apache.http.entity.InputStreamEntity; import org.apache.http.impl.client.CloseableHttpClient; import org.apache.http.impl.client.HttpClientBuilder; import org.apache.http.protocol.HTTP; import org.apache.http.protocol.HttpContext; import org.apache.http.util.EntityUtils; import org.eclipse.jgit.http.server.GitSmartHttpTools; import org.eclipse.jgit.lib.Constants; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.context.RefreshedContextAttacher; import org.springframework.stereotype.Controller; import org.springframework.util.SystemPropertyUtils; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMethod; /** * @author Lyor Goldstein * @since Sep 12, 2013 1:17:34 PM */ @Controller // TODO make it a @ManagedObject and expose internal configuration values for JMX management (Read/Write) public class GitController extends RefreshedContextAttacher implements DisposableBean { public static final Set<String> ALLOWED_SERVICES = Collections.unmodifiableSet( new TreeSet<String>(Arrays.asList(GitSmartHttpTools.UPLOAD_PACK, GitSmartHttpTools.RECEIVE_PACK))); public static final String LOOP_DETECT_TIMEOUT = "gitcloud.frontend.git.controller.loop.detect.timeout"; public static final long DEFAULT_LOOP_DETECT_TIMEOUT = 0L; // disabled private static final String LOOP_DETECT_TIMEOUT_VALUE = SystemPropertyUtils.PLACEHOLDER_PREFIX + LOOP_DETECT_TIMEOUT + SystemPropertyUtils.VALUE_SEPARATOR + DEFAULT_LOOP_DETECT_TIMEOUT + SystemPropertyUtils.PLACEHOLDER_SUFFIX; // TODO move this to some 'util' artifact public static final RedirectStrategy NO_REDIRECTION = new RedirectStrategy() { @Override public boolean isRedirected(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException { return false; } @Override public HttpUriRequest getRedirect(HttpRequest request, HttpResponse response, HttpContext context) throws ProtocolException { throw new ProtocolException("getRedirect(" + request + ")[" + response + "] N/A"); } }; private final MBeanServer mbeanServer; private final CloseableHttpClient client; private final long loopRetryTimeout; private volatile long initTimestamp = System.currentTimeMillis(); private volatile boolean loopDetected; @Inject public GitController(MBeanServer localMbeanServer, HttpClientConnectionManager connectionsManager, @Value(LOOP_DETECT_TIMEOUT_VALUE) long loopDetectTimeout) { mbeanServer = Validate.notNull(localMbeanServer, "No MBean server", ArrayUtils.EMPTY_OBJECT_ARRAY); client = HttpClientBuilder.create().setConnectionManager( Validate.notNull(connectionsManager, "No connections manager", ArrayUtils.EMPTY_OBJECT_ARRAY)) .setRedirectStrategy(NO_REDIRECTION).build(); loopRetryTimeout = loopDetectTimeout; } @Override public void destroy() throws Exception { logger.info("destroy()"); client.close(); } @Override protected void onContextInitialized(ApplicationContext context) { super.onContextInitialized(context); initTimestamp = System.currentTimeMillis(); logger.info("MBeanServer default domain: " + mbeanServer.getDefaultDomain()); String[] domains = mbeanServer.getDomains(); if (!ArrayUtils.isEmpty(domains)) { for (String d : domains) { logger.info("MBeanServer extra domain: " + d); } } } @RequestMapping(method = RequestMethod.GET) public void serveGetRequests(HttpServletRequest req, HttpServletResponse rsp) throws IOException, ServletException { serveRequest(RequestMethod.GET, req, rsp); } @RequestMapping(method = RequestMethod.POST) public void servePostRequests(HttpServletRequest req, HttpServletResponse rsp) throws IOException, ServletException { serveRequest(RequestMethod.POST, req, rsp); } private void serveRequest(RequestMethod method, HttpServletRequest req, HttpServletResponse rsp) throws IOException, ServletException { if (logger.isDebugEnabled()) { logger.debug("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]"); } if ((loopRetryTimeout > 0L) && (!loopDetected)) { long now = System.currentTimeMillis(), diff = now - initTimestamp; if ((diff > 0L) && (diff < loopRetryTimeout)) { try { MBeanInfo mbeanInfo = mbeanServer.getMBeanInfo(new ObjectName( "net.community.chest.gitcloud.facade.backend.git:name=BackendRepositoryResolver")); if (mbeanInfo != null) { logger.info("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " detected loop: " + mbeanInfo.getClassName() + "[" + mbeanInfo.getDescription() + "]"); loopDetected = true; } } catch (JMException e) { if (logger.isDebugEnabled()) { logger.debug("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " failed " + e.getClass().getSimpleName() + " to detect loop: " + e.getMessage()); } } } } ResolvedRepositoryData repoData = resolveTargetRepository(method, req); if (repoData == null) { throw ExtendedLogUtils.thrownLogging(logger, Level.WARNING, "serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]", new NoSuchElementException("Failed to resolve repository")); } String username = authenticate(req); // TODO check if the user is allowed to access the repository via the resolve operation (push/pull) if at all (e.g., private repo) logger.info("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "] user=" + username); /* * NOTE: this feature requires enabling cross-context forwarding. * In Tomcat, the 'crossContext' attribute in 'Context' element of * 'TOMCAT_HOME\conf\context.xml' must be set to true, to enable cross-context */ if (loopDetected) { // TODO see if can find a more efficient way than splitting and re-constructing URI uri = repoData.getRepoLocation(); ServletContext curContext = req.getServletContext(); String urlPath = uri.getPath(), urlQuery = uri.getQuery(); String[] comps = StringUtils.split(urlPath, '/'); String appName = comps[0]; ServletContext loopContext = Validate.notNull(curContext.getContext("/" + appName), "No cross-context for %s", appName); // build the relative path in the re-directed context StringBuilder sb = new StringBuilder( urlPath.length() + 1 + (StringUtils.isEmpty(urlQuery) ? 0 : urlQuery.length())); for (int index = 1; index < comps.length; index++) { sb.append('/').append(comps[index]); } if (!StringUtils.isEmpty(urlQuery)) { sb.append('?').append(urlQuery); } String redirectPath = sb.toString(); RequestDispatcher dispatcher = Validate.notNull(loopContext.getRequestDispatcher(redirectPath), "No dispatcher for %s", redirectPath); dispatcher.forward(req, rsp); if (logger.isDebugEnabled()) { logger.debug("serveRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " forwarded to " + loopContext.getContextPath() + "/" + redirectPath); } } else { executeRemoteRequest(method, repoData.getRepoLocation(), req, rsp); } } String authenticate(HttpServletRequest req) throws IOException { Principal principal = req.getUserPrincipal(); // check if already authenticated String username = (principal == null) ? null : principal.getName(); if (!StringUtils.isEmpty(username)) { if (logger.isDebugEnabled()) { logger.debug("authenticate(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " using principal=" + username); } return username; } // TODO try to authenticate by cookie (if feature allowed) - see GitBlit#authenticate String authorization = StringUtils.trimToEmpty(req.getHeader(AUTH.WWW_AUTH_RESP)); if (StringUtils.isEmpty(authorization)) { if (logger.isDebugEnabled()) { logger.debug("authenticate(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "] no authorization data"); } return null; } // TODO add support for more authorization schemes - including password-less HTTP if (!authorization.startsWith(AuthSchemes.BASIC)) { logger.warn("authenticate(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " unsupported authentication scheme: " + authorization); return null; } String b64Credentials = authorization.substring(AuthSchemes.BASIC.length()).trim(); byte[] credBytes = Base64.decodeBase64(b64Credentials); String credentials = new String(credBytes, Charset.forName("UTF-8")); String[] credValues = StringUtils.split(credentials, ':'); Validate.isTrue(credValues.length == 2, "Bad " + AuthSchemes.BASIC + " credentials format: %s", credentials); username = StringUtils.trimToEmpty(credValues[0]); String password = StringUtils.trimToEmpty(credValues[1]); if (authenticate(username, password)) { return username; } else { return null; } } boolean authenticate(String username, String password) { Validate.notEmpty(username, "No username", ArrayUtils.EMPTY_OBJECT_ARRAY); Validate.notEmpty(password, "No password", ArrayUtils.EMPTY_OBJECT_ARRAY); // TODO inject an AuthenticationProvider or Manager and use it logger.warn("authenticate(" + username + ") N/A"); return false; } private void executeRemoteRequest(RequestMethod method, URI uri, HttpServletRequest req, HttpServletResponse rsp) throws IOException { if (logger.isDebugEnabled()) { logger.debug("executeRemoteRequest(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " redirected to " + uri.toASCIIString()); } HttpRequestBase request = resolveRequest(method, uri); executeRemoteRequest(request, req, rsp); } private StatusLine executeRemoteRequest(HttpRequestBase request, HttpServletRequest req, HttpServletResponse rsp) throws IOException { copyRequestHeadersValues(req, request); final CloseableHttpResponse response; if (HttpPost.METHOD_NAME.equalsIgnoreCase(request.getMethod())) { response = transferPostedData((HttpEntityEnclosingRequestBase) request, req); } else { response = client.execute(request); } try { HttpEntity rspEntity = response.getEntity(); StatusLine statusLine = response.getStatusLine(); int statusCode = statusLine.getStatusCode(); if ((statusCode < HttpServletResponse.SC_OK) || (statusCode >= 300)) { String reason = StringUtils.trimToEmpty(statusLine.getReasonPhrase()); logger.warn("executeRemoteRequest(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " bad response (" + statusCode + ") from remote end: " + reason); EntityUtils.consume(rspEntity); rsp.sendError(statusCode, reason); } else { rsp.setStatus(statusCode); copyResponseHeadersValues(req, response, rsp); transferBackendResponse(req, rspEntity, rsp); } return statusLine; } finally { response.close(); } } private CloseableHttpResponse transferPostedData(HttpEntityEnclosingRequestBase postRequest, final HttpServletRequest req) throws IOException { InputStream postData = req.getInputStream(); try { if (logger.isTraceEnabled()) { LineLevelAppender appender = new LineLevelAppender() { @Override @SuppressWarnings("synthetic-access") public void writeLineData(CharSequence lineData) throws IOException { logger.trace("transferPostedData(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "] C: " + lineData); } @Override public boolean isWriteEnabled() { return true; } }; postData = new TeeInputStream(postData, new HexDumpOutputStream(appender), true); } postRequest.setEntity(new InputStreamEntity(postData)); return client.execute(postRequest); } finally { postData.close(); } } private void transferBackendResponse(final HttpServletRequest req, HttpEntity rspEntity, HttpServletResponse rsp) throws IOException { final String method = req.getMethod(); OutputStream rspTarget = rsp.getOutputStream(); try { if (logger.isTraceEnabled()) { LineLevelAppender appender = new LineLevelAppender() { @Override @SuppressWarnings("synthetic-access") public void writeLineData(CharSequence lineData) throws IOException { logger.trace("transferBackendResponse(" + method + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " S: " + lineData); } @Override public boolean isWriteEnabled() { return true; } }; rspTarget = new TeeOutputStream(rspTarget, new HexDumpOutputStream(appender)); } rspEntity.writeTo(rspTarget); } finally { rspTarget.close(); } } private ResolvedRepositoryData resolveTargetRepository(RequestMethod method, HttpServletRequest req) throws IOException { ResolvedRepositoryData repoData = new ResolvedRepositoryData(); String op = StringUtils.trimToEmpty(req.getParameter("service")), uriPath = req.getPathInfo(); if (StringUtils.isEmpty(op)) { int pos = uriPath.lastIndexOf('/'); if ((pos > 0) && (pos < (uriPath.length() - 1))) { op = uriPath.substring(pos + 1); } } if (!ALLOWED_SERVICES.contains(op)) { throw ExtendedLogUtils.thrownLogging(logger, Level.WARNING, "resolveTargetRepository(" + method + " " + uriPath + ")", new UnsupportedOperationException("Unsupported operation: " + op)); } repoData.setOperation(op); String repoName = extractRepositoryName(uriPath); if (StringUtils.isEmpty(repoName)) { throw ExtendedLogUtils.thrownLogging(logger, Level.WARNING, "resolveTargetRepository(" + method + " " + uriPath + ")", new IllegalArgumentException("Failed to extract repo name from " + uriPath)); } repoData.setRepoName(repoName); // TODO access an injected resolver that returns the back-end location URL String query = req.getQueryString(); try { if (StringUtils.isEmpty(query)) { repoData.setRepoLocation(new URI("http://localhost:8080/git-backend/git" + uriPath)); } else { repoData.setRepoLocation(new URI("http://localhost:8080/git-backend/git" + uriPath + "?" + query)); } } catch (URISyntaxException e) { throw new MalformedURLException(e.getClass().getSimpleName() + ": " + e.getMessage()); } return repoData; } // TODO move this to a common location for the front-end - to be used by SSH as well public static class ResolvedRepositoryData extends AbstractSimpleJavaBean implements Cloneable, Serializable { private static final long serialVersionUID = -2619946255863098003L; private String operation; private String repoName; private URI repoLocation; public ResolvedRepositoryData() { super(); } public String getOperation() { return operation; } public void setOperation(String op) { operation = op; } public String getRepoName() { return repoName; } public void setRepoName(String name) { repoName = name; } public URI getRepoLocation() { return repoLocation; } public void setRepoLocation(URI location) { repoLocation = location; } @Override public ResolvedRepositoryData clone() { try { return getClass().cast(super.clone()); } catch (CloneNotSupportedException e) { throw new RuntimeException(e); // unexpected } } } public static final SortedSet<String> FILTERED_REQUEST_HEADERS = SetUtils .unmodifiableSortedSet(new TreeSet<String>(String.CASE_INSENSITIVE_ORDER) { // we're not serializing it anywhere private static final long serialVersionUID = 1L; { // see RequestContent#process method add(HTTP.TRANSFER_ENCODING); add(HTTP.CONTENT_LEN); // other headers we don't want to echo as-is add(HTTP.CONN_DIRECTIVE); add(HTTP.TARGET_HOST); } }); // TODO move this to some generic util location // NOTE: returns ALL request headers - even those that were filtered out private Map<String, String> copyRequestHeadersValues(HttpServletRequest req, HttpRequestBase request) { Map<String, String> hdrsMap = ServletUtils.getRequestHeaders(req); for (Map.Entry<String, String> hdrEntry : hdrsMap.entrySet()) { String hdrName = hdrEntry.getKey(), hdrValue = StringUtils.trimToEmpty(hdrEntry.getValue()); if (StringUtils.isEmpty(hdrValue)) { logger.warn("copyRequestHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " no value for header " + hdrName); } if (FILTERED_REQUEST_HEADERS.contains(hdrName)) { if (logger.isTraceEnabled()) { logger.trace("copyRequestHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " filtered " + hdrName + ": " + hdrValue); } } else { if (logger.isTraceEnabled()) { logger.trace("copyRequestHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " " + hdrName + ": " + hdrValue); } request.addHeader(hdrName, hdrValue); } hdrsMap.put(hdrName, hdrValue); } return hdrsMap; } public static final SortedSet<String> FILTERED_RESPONSE_HEADERS = SetUtils .unmodifiableSortedSet(new TreeSet<String>(String.CASE_INSENSITIVE_ORDER) { // we're not serializing it anywhere private static final long serialVersionUID = 1L; { /* * Apache HTTP client de-chunks it for us and subsequently * the HttpServletResponse implementation decides how to * re-wrap it */ add(HTTP.TRANSFER_ENCODING); // other headers we don't want to echo as-is add(HTTP.SERVER_HEADER); } }); public static final Comparator<Header> BY_NAME_COMPARATOR = new Comparator<Header>() { @Override public int compare(Header h1, Header h2) { String n1 = (h1 == null) ? null : h1.getName(); String n2 = (h2 == null) ? null : h2.getName(); // header names are case-insensitive return ExtendedStringUtils.safeCompare(n1, n2, false); } }; // TODO move this to some generic util location // NOTE: returns ALL response headers - even those that were filtered out private Map<String, String> copyResponseHeadersValues(HttpServletRequest req, HttpMessage response, HttpServletResponse rsp) { Header[] hdrs = response.getAllHeaders(); if (ArrayUtils.isEmpty(hdrs)) { logger.warn("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "] no headers"); return Collections.emptyMap(); } Arrays.sort(hdrs, BY_NAME_COMPARATOR); // NOTE: map must be case insensitive as per HTTP requirements Map<String, String> hdrsMap = new TreeMap<String, String>(String.CASE_INSENSITIVE_ORDER); for (Header hdrEntry : hdrs) { // TODO add support for multi-valued headers String hdrName = ServletUtils.capitalizeHttpHeaderName(hdrEntry.getName()), hdrValue = StringUtils.trimToEmpty(hdrEntry.getValue()); hdrsMap.put(hdrName, hdrValue); if (FILTERED_RESPONSE_HEADERS.contains(hdrName)) { if (logger.isTraceEnabled()) { logger.trace("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " filtered " + hdrName + ": " + hdrValue); } continue; } if (StringUtils.isEmpty(hdrValue)) { logger.warn("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " no value for header " + hdrName); rsp.setHeader(hdrName, ""); continue; } if (logger.isTraceEnabled()) { logger.trace("copyResponseHeadersValues(" + req.getMethod() + ")[" + req.getRequestURI() + "][" + req.getQueryString() + "]" + " " + hdrName + ": " + hdrValue); } rsp.setHeader(hdrName, hdrValue); } return hdrsMap; } static final HttpRequestBase resolveRequest(RequestMethod method, URI uri) { if (RequestMethod.GET.equals(method)) { return new HttpGet(uri); } else if (RequestMethod.POST.equals(method)) { return new HttpPost(uri); } else { // TODO add support for HEAD, OPTIONS, TRACE, PUT if necessary throw new UnsupportedOperationException(uri.toString() + " - unknown method: " + method); } } // TODO move this to some generic util location public static final String extractRepositoryName(String uriPath) { if (StringUtils.isEmpty(uriPath)) { return null; } int gitPos = uriPath.indexOf(Constants.DOT_GIT_EXT); if (gitPos <= 0) { return null; } int startPos = gitPos; for (; startPos >= 0; startPos--) { if (uriPath.charAt(startPos) == '/') { startPos++; break; } } if (startPos < 0) { startPos = 0; // in case did not start with '/' } int endPos = gitPos; for (; endPos < uriPath.length(); endPos++) { if (uriPath.charAt(endPos) == '/') { break; } } String pureName = uriPath.substring(startPos, endPos); if (Constants.DOT_GIT_EXT.equals(pureName)) { return null; } else { return pureName; } } }