package com.atlassian.labs.speakeasy.proxy;
import com.atlassian.applinks.api.*;
import com.atlassian.applinks.api.application.bamboo.BambooApplicationType;
import com.atlassian.applinks.api.application.confluence.ConfluenceApplicationType;
import com.atlassian.applinks.api.application.fecru.FishEyeCrucibleApplicationType;
import com.atlassian.applinks.api.application.jira.JiraApplicationType;
import com.atlassian.applinks.api.application.refapp.RefAppApplicationType;
import com.atlassian.labs.speakeasy.external.SpeakeasyService;
import com.atlassian.labs.speakeasy.external.UnauthorizedAccessException;
import com.atlassian.labs.speakeasy.manager.PermissionManager;
import com.atlassian.labs.speakeasy.model.Permission;
import com.atlassian.labs.speakeasy.util.JsonObjectMapper;
import com.atlassian.sal.api.net.Request;
import com.atlassian.sal.api.net.Response;
import com.atlassian.sal.api.net.ResponseException;
import com.atlassian.sal.api.net.ResponseHandler;
import com.atlassian.sal.api.user.UserManager;
import com.google.common.collect.ImmutableMap;
import com.google.common.collect.ImmutableSet;
import org.apache.commons.io.IOUtils;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import javax.servlet.ServletOutputStream;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.InputStream;
import java.io.PrintWriter;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.*;
/**
* Proxies requests to an application link
*/
@Component
public class ProxyService
{
private static final String APP_TYPE = "appType";
private static final String APP_ID = "appId";
private static final String FORMAT_ERRORS = "formatErrors";
private static final String PATH = "path";
private final static Set<String> RESERVED_PARAMS = ImmutableSet.of(PATH, APP_ID, APP_TYPE, FORMAT_ERRORS);
private final static Map<String,Class<? extends ApplicationType>> APPLINKS_TYPE_ALIASES = ImmutableMap.<String,Class<? extends ApplicationType>>builder().
put("jira", JiraApplicationType.class).
put("confluence", ConfluenceApplicationType.class).
put("fecru", FishEyeCrucibleApplicationType.class).
put("fisheye", FishEyeCrucibleApplicationType.class).
put("crucible", FishEyeCrucibleApplicationType.class).
put("bamboo", BambooApplicationType.class).
put("refapp", RefAppApplicationType.class).
build();
private final ApplicationLinkService appLinkService;
private final SpeakeasyService speakeasyService;
private final UserManager userManager;
private final PermissionManager permissionManager;
@Autowired
public ProxyService(ApplicationLinkService appLinkService, SpeakeasyService speakeasyService, UserManager userManager, PermissionManager permissionManager)
{
this.appLinkService = appLinkService;
this.speakeasyService = speakeasyService;
this.userManager = userManager;
this.permissionManager = permissionManager;
}
@SuppressWarnings("unchecked")
public int proxy(final HttpServletRequest req, final HttpServletResponse resp, final Request.MethodType methodType) throws UnauthorizedAccessException, IOException
{
String user = userManager.getRemoteUsername(req);
if (!speakeasyService.canAccessSpeakeasy(user))
{
throw new UnauthorizedAccessException(user, "Must be able to access Speakeasy to proxy requests");
}
if (!permissionManager.allowsPermission(Permission.APPLINKS_PROXY))
{
throw new UnauthorizedAccessException(user, "Permission to use Application Links proxy not enabled on this instance");
}
try
{
return doProxy(req, resp, methodType);
}
catch (IOException e)
{
resp.sendError(400, "Exception during proxy: " + e.getMessage());
return 400;
}
}
private int doProxy(HttpServletRequest req, final HttpServletResponse resp, Request.MethodType methodType) throws IOException
{
String url = req.getParameter(PATH);
String finalQueryString = buildProxyQueryString(req);
String finalPath = buildUrlPath(methodType, url, finalQueryString, resp);
if (finalPath == null)
{
return 400;
}
String appId = req.getParameter(APP_ID);
String appType = req.getParameter(APP_TYPE);
ApplicationLink appLink = getApplicationLink(resp, appId, appType);
try
{
final ApplicationLinkRequestFactory requestFactory = appLink.createAuthenticatedRequestFactory();
Request request = prepareRequest(req, methodType, finalPath, requestFactory);
request.execute(new ProxyResponseHandler(resp));
}
catch(ResponseException re)
{
final String finalUrl = appLink.getRpcUrl() + finalPath;
return handleProxyingException(finalUrl, req, resp, re);
}
catch (CredentialsRequiredException e)
{
return oauthChallenge(appLink, resp, e);
}
return 200;
}
private String buildUrlPath(Request.MethodType methodType, String url, String queryString, HttpServletResponse resp) throws IOException
{
if (url == null)
{
resp.sendError(400, "Target url not specified via 'path' query parameter");
return null;
}
if (methodType == Request.MethodType.GET && queryString .length() > 0)
{
url = url + (url.contains("?") ? '&' : '?') + queryString;
}
return url;
}
private String buildProxyQueryString(HttpServletRequest req)
{
String queryString = "";
Map<String,String[]> parameters = req.getParameterMap();
for (String name : parameters.keySet())
{
if (RESERVED_PARAMS.contains(name))
{
continue;
}
Object val = parameters.get(name);
if (val instanceof String[])
{
String[] params = (String[])val;
for (String param : params)
{
queryString = queryString + (queryString.length() > 0 ? "&" : "") + encode(name) + "=" + encode(param);;
}
}
else
{
queryString = queryString + (queryString.length() > 0 ? "&" : "") + encode(name) + "=" + encode(req.getParameter(name));
}
}
return queryString;
}
private ApplicationLink getApplicationLink(HttpServletResponse resp, String appId, String appType) throws IOException
{
if (appType == null && appId == null)
{
resp.sendError(400, "You must specify an appId or appType request parameter");
}
ApplicationLink appLink = null;
if (appId != null)
{
try
{
appLink = getApplicationLinkByIdOrName(appId);
if (appLink == null)
{
resp.sendError(404, "No Application Link found for the id " + appId);
}
}
catch (TypeNotInstalledException e)
{
resp.sendError(404, "No Application Link found for the id " + appId);
}
}
else if (appType != null)
{
try
{
appLink = getPrimaryAppLinkByType(appType);
if (appLink == null)
{
resp.sendError(404, "No Application Link found for the type " + appType);
}
}
catch (ClassNotFoundException e)
{
resp.sendError(404, "Application Link type not found " + appType);
}
}
else
{
resp.sendError(400, "Application Link type 'appType' or id 'appId' not specified as a query parameter");
}
return appLink;
}
private String encode(String value)
{
try
{
return URLEncoder.encode(value, "UTF-8");
}
catch (UnsupportedEncodingException ex)
{
// should never happen
throw new RuntimeException(ex);
}
}
private int oauthChallenge(ApplicationLink appLink, HttpServletResponse resp, CredentialsRequiredException e) throws IOException
{
resp.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
final String authUri = e.getAuthorisationURI().toString();
resp.setHeader("WWW-Authenticate", "OAuth realm=\"" + authUri + "\"");
JsonObjectMapper.write(new OAuthAuthenticateResponse(appLink, authUri), resp.getWriter());
return HttpServletResponse.SC_UNAUTHORIZED;
}
private int handleProxyingException(String finalUrl, HttpServletRequest req, HttpServletResponse resp, Exception e) throws IOException
{
final boolean format = Boolean.parseBoolean(req.getParameter(FORMAT_ERRORS));
String errorMsg = "There was an error proxying your request to " + finalUrl + " because of " + e.getMessage();
if (format)
{
formatError(resp, errorMsg);
}
else
{
resp.sendError(504, errorMsg);
return 504;
}
return 400;
}
private void formatError(HttpServletResponse resp, String errorMsg)
throws IOException
{
PrintWriter writer = resp.getWriter();
writer.write("<h4>" + errorMsg+ "</h4>");
writer.flush();
}
private Request prepareRequest(HttpServletRequest req,
Request.MethodType methodType, String url,
final ApplicationLinkRequestFactory requestFactory)
throws CredentialsRequiredException, IOException
{
Request request = requestFactory.createRequest(methodType, url);
// remove xsrf token check on the destination. Assumes this servlet requires an xsrf token
request.setHeader("X-Atlassian-Token", "no-check");
// forward the original ip or pass on already forwarded ip so logging is accurate.
String xForward = req.getHeader("X-Forwarded-For");
request.setHeader("X-Forwarded-For", xForward != null ? xForward : req.getRemoteAddr());
if (methodType == Request.MethodType.POST)
{
String ctHeader = req.getHeader("Content-Type");
if (ctHeader != null)
{
request.setHeader("Content-Type", ctHeader);
}
if (ctHeader != null && ctHeader.contains("application/x-www-form-urlencoded"))
{
List<String> params = new ArrayList<String>();
final Map<String, String[]> parameterMap = (Map<String, String[]>) req.getParameterMap();
for (String name : parameterMap.keySet())
{
if (RESERVED_PARAMS.contains(name))
{
continue;
}
params.add(name);
params.add(req.getParameter(name));
}
request.addRequestParameters((String[]) params.toArray(new String[params.size()]));
}
else
{
String enc = req.getCharacterEncoding();
String str = IOUtils.toString(req.getInputStream(), (enc == null ? "ISO8859_1" : enc));
request.setRequestBody(str);
}
}
return request;
}
@SuppressWarnings("unchecked")
private ApplicationLink getPrimaryAppLinkByType(String type) throws ClassNotFoundException
{
Class<? extends ApplicationType> clazz = APPLINKS_TYPE_ALIASES.get(type.toLowerCase(Locale.US));
if (clazz == null)
{
clazz = (Class<? extends ApplicationType>) getClass().getClassLoader().loadClass(type);
}
return appLinkService.getPrimaryApplicationLink(clazz);
}
private ApplicationLink getApplicationLinkByIdOrName(String id) throws TypeNotInstalledException
{
ApplicationId appId = null;
try
{
appId = new ApplicationId(id);
}
catch (IllegalArgumentException ex)
{
// not a valid id, try for name;
}
for (ApplicationLink link : appLinkService.getApplicationLinks())
{
if ((appId == null && link.getName().equals(id)) ||
(appId != null && link.getId().equals(appId)))
{
return link;
}
}
return null;
}
private class ProxyResponseHandler implements ResponseHandler<Response>
{
private final HttpServletResponse resp;
public ProxyResponseHandler(HttpServletResponse resp)
{
this.resp = resp;
}
public void handle(Response response) throws ResponseException
{
if (response.isSuccessful())
{
InputStream responseStream = response.getResponseBodyAsStream();
Map<String, String> headers = response.getHeaders();
for (String key : headers.keySet())
{
// don't pass on cookies set by linked application.
if (key.equalsIgnoreCase("Set-Cookie"))
{
continue;
}
resp.setHeader(key, headers.get(key));
}
try
{
if (responseStream != null)
{
ServletOutputStream outputStream = resp.getOutputStream();
IOUtils.copy(responseStream, outputStream);
outputStream.flush();
outputStream.close();
}
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
else
{
try
{
formatError(resp, "Request failed, check your configuration.");
}
catch (IOException e)
{
throw new RuntimeException(e);
}
}
}
}
}