/**
* This file is part of git-as-svn. It is subject to the license terms
* in the LICENSE file found in the top-level directory of this distribution
* and at http://www.gnu.org/licenses/gpl-2.0.html. No part of git-as-svn,
* including this file, may be copied, modified, propagated, or distributed
* except according to the terms contained in the LICENSE file.
*/
package svnserver.ext.web.server;
import com.fasterxml.jackson.annotation.JsonInclude;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.SerializationFeature;
import org.apache.http.HttpHeaders;
import org.eclipse.jetty.server.Request;
import org.eclipse.jetty.server.RequestLog;
import org.eclipse.jetty.server.Response;
import org.eclipse.jetty.server.Server;
import org.eclipse.jetty.server.handler.ErrorHandler;
import org.eclipse.jetty.server.handler.HandlerCollection;
import org.eclipse.jetty.server.handler.RequestLogHandler;
import org.eclipse.jetty.servlet.ServletContextHandler;
import org.eclipse.jetty.servlet.ServletHandler;
import org.eclipse.jetty.servlet.ServletHolder;
import org.eclipse.jetty.servlet.ServletMapping;
import org.eclipse.jgit.util.Base64;
import org.jetbrains.annotations.NotNull;
import org.jetbrains.annotations.Nullable;
import org.jose4j.jwe.JsonWebEncryption;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.tmatesoft.svn.core.SVNException;
import ru.bozaro.gitlfs.server.ServerError;
import svnserver.auth.User;
import svnserver.auth.UserDB;
import svnserver.context.Shared;
import svnserver.context.SharedContext;
import svnserver.ext.web.config.WebServerConfig;
import svnserver.ext.web.token.EncryptionFactory;
import svnserver.ext.web.token.TokenHelper;
import javax.servlet.Servlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import java.io.IOException;
import java.io.StringWriter;
import java.net.URI;
import java.nio.charset.StandardCharsets;
import java.util.ArrayList;
import java.util.Collection;
import java.util.List;
import java.util.Map;
import java.util.concurrent.CopyOnWriteArrayList;
/**
* Web server component
*
* @author Artem V. Navrotskiy <bozaro@users.noreply.github.com>
*/
public class WebServer implements Shared {
@NotNull
private static final Logger log = LoggerFactory.getLogger(WebServer.class);
@NotNull
public static final String DEFAULT_REALM = "Git as Subversion server";
@NotNull
public static final String AUTH_BASIC = "Basic ";
@NotNull
public static final String AUTH_TOKEN = "Bearer ";
@NotNull
private final SharedContext context;
@Nullable
private final Server server;
@Nullable
private final ServletHandler handler;
@NotNull
private final WebServerConfig config;
@NotNull
private final EncryptionFactory tokenFactory;
@NotNull
private final List<Holder> servlets = new CopyOnWriteArrayList<>();
public WebServer(@NotNull SharedContext context, @Nullable Server server, @NotNull WebServerConfig config, @NotNull EncryptionFactory tokenFactory) {
this.context = context;
this.server = server;
this.config = config;
this.tokenFactory = tokenFactory;
if (server != null) {
final ServletContextHandler contextHandler = new ServletContextHandler();
contextHandler.setContextPath("/");
handler = contextHandler.getServletHandler();
//final ConstraintSecurityHandler securityHandler = new ConstraintSecurityHandler();
//securityHandler.addConstraintMapping(new );
//contextHandler.setSecurityHandler(securityHandler);
final RequestLogHandler logHandler = new RequestLogHandler();
logHandler.setRequestLog(new RequestLog() {
@Override
public void log(Request request, Response response) {
final User user = (User) request.getAttribute(User.class.getName());
final String userName = (user == null || user.isAnonymous()) ? "" : user.getUserName();
log.info("{}:{} - {} - \"{} {}\" {} {}", request.getRemoteHost(), request.getRemotePort(), userName, request.getMethod(), request.getHttpURI(), response.getStatus(), response.getReason());
}
});
final HandlerCollection handlers = new HandlerCollection();
handlers.addHandler(contextHandler);
handlers.addHandler(logHandler);
server.setHandler(handlers);
} else {
handler = null;
}
}
@NotNull
public String getRealm() {
return config.getRealm();
}
@NotNull
public JsonWebEncryption createEncryption() {
return tokenFactory.create();
}
@Override
public void ready(@NotNull SharedContext context) throws IOException {
try {
if (server != null) {
server.start();
}
} catch (Exception e) {
throw new IOException("Can't start http server", e);
}
}
@NotNull
public Holder addServlet(@NotNull String pathSpec, @NotNull Servlet servlet) {
log.info("Registered servlet for path: {}", pathSpec);
final Holder servletInfo = new Holder(pathSpec, servlet);
servlets.add(servletInfo);
updateServlets();
return servletInfo;
}
@NotNull
public Collection<Holder> addServlets(@NotNull Map<String, Servlet> servletMap) {
List<Holder> servletInfos = new ArrayList<>();
for (Map.Entry<String, Servlet> entry : servletMap.entrySet()) {
log.info("Registered servlet for path: {}", entry.getKey());
final Holder servletInfo = new Holder(entry.getKey(), entry.getValue());
servletInfos.add(servletInfo);
}
servlets.addAll(servletInfos);
updateServlets();
return servletInfos;
}
public void removeServlet(@NotNull Holder servletInfo) {
if (servlets.remove(servletInfo)) {
log.info("Unregistered servlet for path: {}", servletInfo.path);
updateServlets();
}
}
public void removeServlets(@NotNull Collection<Holder> servletInfos) {
boolean modified = false;
for (Holder servlet : servletInfos) {
if (servlets.remove(servlet)) {
log.info("Unregistered servlet for path: {}", servlet.path);
modified = true;
}
}
if (modified) {
updateServlets();
}
}
private void updateServlets() {
if (handler != null) {
final Holder[] snapshot = servlets.toArray(new Holder[servlets.size()]);
final ServletHolder[] holders = new ServletHolder[snapshot.length];
final ServletMapping[] mappings = new ServletMapping[snapshot.length];
for (int i = 0; i < snapshot.length; ++i) {
holders[i] = snapshot[i].holder;
mappings[i] = snapshot[i].mapping;
}
handler.setServlets(holders);
handler.setServletMappings(mappings);
}
}
@Override
public void close() throws Exception {
if (server != null) {
server.stop();
server.join();
}
}
@NotNull
public static WebServer get(@NotNull SharedContext context) throws IOException {
return context.getOrCreate(WebServer.class, () -> new WebServer(context, null, new WebServerConfig(), JsonWebEncryption::new));
}
/**
* Return current user information.
*
* @param authorization HTTP authorization header value.
* @return Return value:
* <ul>
* <li>no authorization header - anonymous user;</li>
* <li>invalid authorization header - null;</li>
* <li>valid authorization header - user information.</li>
* </ul>
*/
@Nullable
public User getAuthInfo(@Nullable final String authorization, int tokenEnsureTime) {
final UserDB userDB = context.sure(UserDB.class);
// Check HTTP authorization.
if (authorization == null) {
return User.getAnonymous();
}
if (authorization.startsWith(AUTH_BASIC)) {
final String raw = new String(Base64.decode(authorization.substring(AUTH_BASIC.length()).trim()), StandardCharsets.UTF_8);
final int separator = raw.indexOf(':');
if (separator > 0) {
final String username = raw.substring(0, separator);
final String password = raw.substring(separator + 1);
try {
return userDB.check(username, password);
} catch (IOException | SVNException e) {
log.error("Authorization error: " + e.getMessage(), e);
}
}
return null;
}
if (authorization.startsWith(AUTH_TOKEN)) {
return TokenHelper.parseToken(createEncryption(), authorization.substring(AUTH_TOKEN.length()).trim(), tokenEnsureTime);
}
return null;
}
@NotNull
public URI getUrl(@NotNull HttpServletRequest req) {
if (config.getBaseUrl() != null) {
return URI.create(config.getBaseUrl()).resolve(req.getRequestURI());
}
String host = req.getHeader(HttpHeaders.HOST);
if (host == null) {
host = req.getServerName() + ":" + req.getServerPort();
}
return URI.create(req.getScheme() + "://" + host + req.getRequestURI());
}
@NotNull
public URI getUrl(@NotNull URI baseUri) {
if (config.getBaseUrl() != null) {
return URI.create(config.getBaseUrl()).resolve(baseUri.getPath());
}
return baseUri;
}
@NotNull
public static ObjectMapper createJsonMapper() {
final ObjectMapper mapper = new ObjectMapper();
mapper.configure(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS, false);
mapper.configure(SerializationFeature.INDENT_OUTPUT, true);
mapper.setSerializationInclusion(JsonInclude.Include.NON_NULL);
return mapper;
}
public void sendError(@NotNull HttpServletRequest req, @NotNull HttpServletResponse resp, @NotNull ServerError error) throws IOException {
resp.setContentType("text/html");
resp.setStatus(error.getStatusCode());
resp.getWriter().write(new ErrorWriter(req).content(error));
}
public final class Holder {
@NotNull
private final String path;
@NotNull
private final ServletHolder holder;
@NotNull
private final ServletMapping mapping;
private Holder(@NotNull String pathSpec, @NotNull Servlet servlet) {
path = pathSpec;
holder = new ServletHolder(servlet);
mapping = new ServletMapping();
mapping.setServletName(holder.getName());
mapping.setPathSpec(pathSpec);
}
public void removeServlet() {
WebServer.this.removeServlet(this);
}
}
private static class ErrorWriter extends ErrorHandler {
private final HttpServletRequest req;
public ErrorWriter(HttpServletRequest req) {
this.req = req;
}
@NotNull
public String content(@NotNull ServerError error) {
try {
final StringWriter writer = new StringWriter();
writeErrorPage(req, writer, error.getStatusCode(), error.getMessage(), false);
return writer.toString();
} catch (IOException e) {
return e.getMessage();
}
}
}
}