forked from Qortal/qortal
Browse Source
This allows Q-Apps and websites to be developed and tested in real time, by proxying an existing webserver such as localhost:5173 from React/Vite. The proxy adds all QDN functionality to the existing server in real time. Needs UI integration before all features can be used.arbitrary-resources-cache
CalDescent
1 year ago
5 changed files with 505 additions and 0 deletions
@ -0,0 +1,173 @@ |
|||||||
|
package org.qortal.api; |
||||||
|
|
||||||
|
import io.swagger.v3.jaxrs2.integration.resources.OpenApiResource; |
||||||
|
import org.eclipse.jetty.http.HttpVersion; |
||||||
|
import org.eclipse.jetty.rewrite.handler.RewriteHandler; |
||||||
|
import org.eclipse.jetty.server.*; |
||||||
|
import org.eclipse.jetty.server.handler.ErrorHandler; |
||||||
|
import org.eclipse.jetty.server.handler.InetAccessHandler; |
||||||
|
import org.eclipse.jetty.servlet.FilterHolder; |
||||||
|
import org.eclipse.jetty.servlet.ServletContextHandler; |
||||||
|
import org.eclipse.jetty.servlet.ServletHolder; |
||||||
|
import org.eclipse.jetty.servlets.CrossOriginFilter; |
||||||
|
import org.eclipse.jetty.util.ssl.SslContextFactory; |
||||||
|
import org.glassfish.jersey.server.ResourceConfig; |
||||||
|
import org.glassfish.jersey.servlet.ServletContainer; |
||||||
|
import org.qortal.api.resource.AnnotationPostProcessor; |
||||||
|
import org.qortal.api.resource.ApiDefinition; |
||||||
|
import org.qortal.network.Network; |
||||||
|
import org.qortal.repository.DataException; |
||||||
|
import org.qortal.settings.Settings; |
||||||
|
|
||||||
|
import javax.net.ssl.KeyManagerFactory; |
||||||
|
import javax.net.ssl.SSLContext; |
||||||
|
import java.io.InputStream; |
||||||
|
import java.net.InetAddress; |
||||||
|
import java.net.InetSocketAddress; |
||||||
|
import java.nio.file.Files; |
||||||
|
import java.nio.file.Path; |
||||||
|
import java.nio.file.Paths; |
||||||
|
import java.security.KeyStore; |
||||||
|
import java.security.SecureRandom; |
||||||
|
|
||||||
|
public class DevProxyService { |
||||||
|
|
||||||
|
private static DevProxyService instance; |
||||||
|
|
||||||
|
private final ResourceConfig config; |
||||||
|
private Server server; |
||||||
|
|
||||||
|
private DevProxyService() { |
||||||
|
this.config = new ResourceConfig(); |
||||||
|
this.config.packages("org.qortal.api.proxy.resource", "org.qortal.api.resource"); |
||||||
|
this.config.register(OpenApiResource.class); |
||||||
|
this.config.register(ApiDefinition.class); |
||||||
|
this.config.register(AnnotationPostProcessor.class); |
||||||
|
} |
||||||
|
|
||||||
|
public static DevProxyService getInstance() { |
||||||
|
if (instance == null) |
||||||
|
instance = new DevProxyService(); |
||||||
|
|
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
public Iterable<Class<?>> getResources() { |
||||||
|
return this.config.getClasses(); |
||||||
|
} |
||||||
|
|
||||||
|
public void start() throws DataException { |
||||||
|
try { |
||||||
|
// Create API server
|
||||||
|
|
||||||
|
// SSL support if requested
|
||||||
|
String keystorePathname = Settings.getInstance().getSslKeystorePathname(); |
||||||
|
String keystorePassword = Settings.getInstance().getSslKeystorePassword(); |
||||||
|
|
||||||
|
if (keystorePathname != null && keystorePassword != null) { |
||||||
|
// SSL version
|
||||||
|
if (!Files.isReadable(Path.of(keystorePathname))) |
||||||
|
throw new RuntimeException("Failed to start SSL API due to broken keystore"); |
||||||
|
|
||||||
|
// BouncyCastle-specific SSLContext build
|
||||||
|
SSLContext sslContext = SSLContext.getInstance("TLS", "BCJSSE"); |
||||||
|
KeyManagerFactory keyManagerFactory = KeyManagerFactory.getInstance("PKIX", "BCJSSE"); |
||||||
|
|
||||||
|
KeyStore keyStore = KeyStore.getInstance(KeyStore.getDefaultType(), "BC"); |
||||||
|
|
||||||
|
try (InputStream keystoreStream = Files.newInputStream(Paths.get(keystorePathname))) { |
||||||
|
keyStore.load(keystoreStream, keystorePassword.toCharArray()); |
||||||
|
} |
||||||
|
|
||||||
|
keyManagerFactory.init(keyStore, keystorePassword.toCharArray()); |
||||||
|
sslContext.init(keyManagerFactory.getKeyManagers(), null, new SecureRandom()); |
||||||
|
|
||||||
|
SslContextFactory.Server sslContextFactory = new SslContextFactory.Server(); |
||||||
|
sslContextFactory.setSslContext(sslContext); |
||||||
|
|
||||||
|
this.server = new Server(); |
||||||
|
|
||||||
|
HttpConfiguration httpConfig = new HttpConfiguration(); |
||||||
|
httpConfig.setSecureScheme("https"); |
||||||
|
httpConfig.setSecurePort(Settings.getInstance().getDevProxyPort()); |
||||||
|
|
||||||
|
SecureRequestCustomizer src = new SecureRequestCustomizer(); |
||||||
|
httpConfig.addCustomizer(src); |
||||||
|
|
||||||
|
HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig); |
||||||
|
SslConnectionFactory sslConnectionFactory = new SslConnectionFactory(sslContextFactory, HttpVersion.HTTP_1_1.asString()); |
||||||
|
|
||||||
|
ServerConnector portUnifiedConnector = new ServerConnector(this.server, |
||||||
|
new DetectorConnectionFactory(sslConnectionFactory), |
||||||
|
httpConnectionFactory); |
||||||
|
portUnifiedConnector.setHost(Network.getInstance().getBindAddress()); |
||||||
|
portUnifiedConnector.setPort(Settings.getInstance().getDevProxyPort()); |
||||||
|
|
||||||
|
this.server.addConnector(portUnifiedConnector); |
||||||
|
} else { |
||||||
|
// Non-SSL
|
||||||
|
InetAddress bindAddr = InetAddress.getByName(Network.getInstance().getBindAddress()); |
||||||
|
InetSocketAddress endpoint = new InetSocketAddress(bindAddr, Settings.getInstance().getDevProxyPort()); |
||||||
|
this.server = new Server(endpoint); |
||||||
|
} |
||||||
|
|
||||||
|
// Error handler
|
||||||
|
ErrorHandler errorHandler = new ApiErrorHandler(); |
||||||
|
this.server.setErrorHandler(errorHandler); |
||||||
|
|
||||||
|
// Request logging
|
||||||
|
if (Settings.getInstance().isDevProxyLoggingEnabled()) { |
||||||
|
RequestLogWriter logWriter = new RequestLogWriter("devproxy-requests.log"); |
||||||
|
logWriter.setAppend(true); |
||||||
|
logWriter.setTimeZone("UTC"); |
||||||
|
RequestLog requestLog = new CustomRequestLog(logWriter, CustomRequestLog.EXTENDED_NCSA_FORMAT); |
||||||
|
this.server.setRequestLog(requestLog); |
||||||
|
} |
||||||
|
|
||||||
|
// Access handler (currently no whitelist is used)
|
||||||
|
InetAccessHandler accessHandler = new InetAccessHandler(); |
||||||
|
this.server.setHandler(accessHandler); |
||||||
|
|
||||||
|
// URL rewriting
|
||||||
|
RewriteHandler rewriteHandler = new RewriteHandler(); |
||||||
|
accessHandler.setHandler(rewriteHandler); |
||||||
|
|
||||||
|
// Context
|
||||||
|
ServletContextHandler context = new ServletContextHandler(ServletContextHandler.NO_SESSIONS); |
||||||
|
context.setContextPath("/"); |
||||||
|
rewriteHandler.setHandler(context); |
||||||
|
|
||||||
|
// Cross-origin resource sharing
|
||||||
|
FilterHolder corsFilterHolder = new FilterHolder(CrossOriginFilter.class); |
||||||
|
corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_ORIGINS_PARAM, "*"); |
||||||
|
corsFilterHolder.setInitParameter(CrossOriginFilter.ALLOWED_METHODS_PARAM, "GET, POST, DELETE"); |
||||||
|
corsFilterHolder.setInitParameter(CrossOriginFilter.CHAIN_PREFLIGHT_PARAM, "false"); |
||||||
|
context.addFilter(corsFilterHolder, "/*", null); |
||||||
|
|
||||||
|
// API servlet
|
||||||
|
ServletContainer container = new ServletContainer(this.config); |
||||||
|
ServletHolder apiServlet = new ServletHolder(container); |
||||||
|
apiServlet.setInitOrder(1); |
||||||
|
context.addServlet(apiServlet, "/*"); |
||||||
|
|
||||||
|
// Start server
|
||||||
|
this.server.start(); |
||||||
|
} catch (Exception e) { |
||||||
|
// Failed to start
|
||||||
|
throw new DataException("Failed to start developer proxy", e); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void stop() { |
||||||
|
try { |
||||||
|
// Stop server
|
||||||
|
this.server.stop(); |
||||||
|
} catch (Exception e) { |
||||||
|
// Failed to stop
|
||||||
|
} |
||||||
|
|
||||||
|
this.server = null; |
||||||
|
instance = null; |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,142 @@ |
|||||||
|
package org.qortal.api.proxy.resource; |
||||||
|
|
||||||
|
import org.qortal.api.ApiError; |
||||||
|
import org.qortal.api.ApiExceptionFactory; |
||||||
|
import org.qortal.api.HTMLParser; |
||||||
|
import org.qortal.arbitrary.misc.Service; |
||||||
|
import org.qortal.controller.DevProxyManager; |
||||||
|
|
||||||
|
import javax.servlet.ServletContext; |
||||||
|
import javax.servlet.http.HttpServletRequest; |
||||||
|
import javax.servlet.http.HttpServletResponse; |
||||||
|
import javax.ws.rs.GET; |
||||||
|
import javax.ws.rs.Path; |
||||||
|
import javax.ws.rs.PathParam; |
||||||
|
import javax.ws.rs.core.Context; |
||||||
|
import java.io.ByteArrayOutputStream; |
||||||
|
import java.io.IOException; |
||||||
|
import java.io.InputStream; |
||||||
|
import java.net.HttpURLConnection; |
||||||
|
import java.net.URL; |
||||||
|
import java.util.Enumeration; |
||||||
|
|
||||||
|
|
||||||
|
@Path("/") |
||||||
|
public class DevProxyServerResource { |
||||||
|
|
||||||
|
@Context HttpServletRequest request; |
||||||
|
@Context HttpServletResponse response; |
||||||
|
@Context ServletContext context; |
||||||
|
|
||||||
|
|
||||||
|
@GET |
||||||
|
public HttpServletResponse getProxyIndex() { |
||||||
|
return this.proxy("/"); |
||||||
|
} |
||||||
|
|
||||||
|
@GET |
||||||
|
@Path("{path:.*}") |
||||||
|
public HttpServletResponse getProxyPath(@PathParam("path") String inPath) { |
||||||
|
return this.proxy(inPath); |
||||||
|
} |
||||||
|
|
||||||
|
private HttpServletResponse proxy(String inPath) { |
||||||
|
try { |
||||||
|
String source = DevProxyManager.getInstance().getSourceHostAndPort(); |
||||||
|
|
||||||
|
// Convert localhost / 127.0.0.1 to IPv6 [::1]
|
||||||
|
if (source.startsWith("localhost") || source.startsWith("127.0.0.1")) { |
||||||
|
int port = 80; |
||||||
|
String[] parts = source.split(":"); |
||||||
|
if (parts.length > 1) { |
||||||
|
port = Integer.parseInt(parts[1]); |
||||||
|
} |
||||||
|
source = String.format("[::1]:%d", port); |
||||||
|
} |
||||||
|
|
||||||
|
if (!inPath.startsWith("/")) { |
||||||
|
inPath = "/" + inPath; |
||||||
|
} |
||||||
|
|
||||||
|
String queryString = request.getQueryString() != null ? "?" + request.getQueryString() : ""; |
||||||
|
|
||||||
|
// Open URL
|
||||||
|
String urlString = String.format("http://%s%s%s", source, inPath, queryString); |
||||||
|
URL url = new URL(urlString); |
||||||
|
HttpURLConnection con = (HttpURLConnection) url.openConnection(); |
||||||
|
con.setRequestMethod(request.getMethod()); |
||||||
|
|
||||||
|
// Proxy the request headers
|
||||||
|
Enumeration<String> headerNames = request.getHeaderNames(); |
||||||
|
while (headerNames.hasMoreElements()) { |
||||||
|
String headerName = headerNames.nextElement(); |
||||||
|
String headerValue = request.getHeader(headerName); |
||||||
|
con.setRequestProperty(headerName, headerValue); |
||||||
|
} |
||||||
|
|
||||||
|
// TODO: proxy any POST parameters from "request" to "con"
|
||||||
|
|
||||||
|
// Proxy the response code
|
||||||
|
int responseCode = con.getResponseCode(); |
||||||
|
response.setStatus(responseCode); |
||||||
|
|
||||||
|
// Proxy the response headers
|
||||||
|
for (int i = 0; ; i++) { |
||||||
|
String headerKey = con.getHeaderFieldKey(i); |
||||||
|
String headerValue = con.getHeaderField(i); |
||||||
|
if (headerKey != null && headerValue != null) { |
||||||
|
response.addHeader(headerKey, headerValue); |
||||||
|
continue; |
||||||
|
} |
||||||
|
break; |
||||||
|
} |
||||||
|
|
||||||
|
// Read the response body
|
||||||
|
InputStream inputStream = con.getInputStream(); |
||||||
|
ByteArrayOutputStream outputStream = new ByteArrayOutputStream(); |
||||||
|
byte[] buffer = new byte[4096]; |
||||||
|
int bytesRead; |
||||||
|
while ((bytesRead = inputStream.read(buffer)) != -1) { |
||||||
|
outputStream.write(buffer, 0, bytesRead); |
||||||
|
} |
||||||
|
byte[] data = outputStream.toByteArray(); // TODO: limit file size that can be read into memory
|
||||||
|
|
||||||
|
// Close the streams
|
||||||
|
outputStream.close(); |
||||||
|
inputStream.close(); |
||||||
|
|
||||||
|
// Extract filename
|
||||||
|
String filename = ""; |
||||||
|
if (inPath.contains("/")) { |
||||||
|
String[] parts = inPath.split("/"); |
||||||
|
if (parts.length > 0) { |
||||||
|
filename = parts[parts.length - 1]; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
// Parse and modify output if needed
|
||||||
|
if (HTMLParser.isHtmlFile(filename)) { |
||||||
|
// HTML file - needs to be parsed
|
||||||
|
HTMLParser htmlParser = new HTMLParser("", inPath, "", false, data, "proxy", Service.APP, null, "light", true); |
||||||
|
htmlParser.addAdditionalHeaderTags(); |
||||||
|
response.addHeader("Content-Security-Policy", "default-src 'self' 'unsafe-inline' 'unsafe-eval'; media-src 'self' data: blob:; img-src 'self' data: blob:; connect-src 'self' ws:; font-src 'self' data:;"); |
||||||
|
response.setContentType(con.getContentType()); |
||||||
|
response.setContentLength(htmlParser.getData().length); |
||||||
|
response.getOutputStream().write(htmlParser.getData()); |
||||||
|
} |
||||||
|
else { |
||||||
|
// Regular file - can be streamed directly
|
||||||
|
response.addHeader("Content-Security-Policy", "default-src 'self'"); |
||||||
|
response.setContentType(con.getContentType()); |
||||||
|
response.setContentLength(data.length); |
||||||
|
response.getOutputStream().write(data); |
||||||
|
} |
||||||
|
|
||||||
|
} catch (IOException e) { |
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); |
||||||
|
} |
||||||
|
|
||||||
|
return response; |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,96 @@ |
|||||||
|
package org.qortal.api.resource; |
||||||
|
|
||||||
|
import io.swagger.v3.oas.annotations.Operation; |
||||||
|
import io.swagger.v3.oas.annotations.media.Content; |
||||||
|
import io.swagger.v3.oas.annotations.media.Schema; |
||||||
|
import io.swagger.v3.oas.annotations.parameters.RequestBody; |
||||||
|
import io.swagger.v3.oas.annotations.responses.ApiResponse; |
||||||
|
import io.swagger.v3.oas.annotations.tags.Tag; |
||||||
|
import org.qortal.api.ApiError; |
||||||
|
import org.qortal.api.ApiErrors; |
||||||
|
import org.qortal.api.ApiExceptionFactory; |
||||||
|
import org.qortal.controller.DevProxyManager; |
||||||
|
import org.qortal.repository.DataException; |
||||||
|
|
||||||
|
import javax.servlet.ServletContext; |
||||||
|
import javax.servlet.http.HttpServletRequest; |
||||||
|
import javax.servlet.http.HttpServletResponse; |
||||||
|
import javax.ws.rs.POST; |
||||||
|
import javax.ws.rs.Path; |
||||||
|
import javax.ws.rs.core.Context; |
||||||
|
import javax.ws.rs.core.MediaType; |
||||||
|
|
||||||
|
|
||||||
|
@Path("/developer") |
||||||
|
@Tag(name = "Developer Tools") |
||||||
|
public class DeveloperResource { |
||||||
|
|
||||||
|
@Context HttpServletRequest request; |
||||||
|
@Context HttpServletResponse response; |
||||||
|
@Context ServletContext context; |
||||||
|
|
||||||
|
|
||||||
|
@POST |
||||||
|
@Path("/proxy/start") |
||||||
|
@Operation( |
||||||
|
summary = "Start proxy server, for real time QDN app/website development", |
||||||
|
requestBody = @RequestBody( |
||||||
|
description = "Host and port of source webserver to be proxied", |
||||||
|
required = true, |
||||||
|
content = @Content( |
||||||
|
mediaType = MediaType.TEXT_PLAIN, |
||||||
|
schema = @Schema( |
||||||
|
type = "string", |
||||||
|
example = "127.0.0.1:5173" |
||||||
|
) |
||||||
|
) |
||||||
|
), |
||||||
|
responses = { |
||||||
|
@ApiResponse( |
||||||
|
description = "Port number of running server", |
||||||
|
content = @Content( |
||||||
|
mediaType = MediaType.TEXT_PLAIN, |
||||||
|
schema = @Schema( |
||||||
|
type = "number" |
||||||
|
) |
||||||
|
) |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
@ApiErrors({ApiError.INVALID_CRITERIA}) |
||||||
|
public Integer startProxy(String sourceHostAndPort) { |
||||||
|
// TODO: API key
|
||||||
|
DevProxyManager devProxyManager = DevProxyManager.getInstance(); |
||||||
|
try { |
||||||
|
devProxyManager.setSourceHostAndPort(sourceHostAndPort); |
||||||
|
devProxyManager.start(); |
||||||
|
return devProxyManager.getPort(); |
||||||
|
|
||||||
|
} catch (DataException e) { |
||||||
|
throw ApiExceptionFactory.INSTANCE.createCustomException(request, ApiError.INVALID_CRITERIA, e.getMessage()); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@POST |
||||||
|
@Path("/proxy/stop") |
||||||
|
@Operation( |
||||||
|
summary = "Stop proxy server", |
||||||
|
responses = { |
||||||
|
@ApiResponse( |
||||||
|
description = "true if stopped", |
||||||
|
content = @Content( |
||||||
|
mediaType = MediaType.TEXT_PLAIN, |
||||||
|
schema = @Schema( |
||||||
|
type = "boolean" |
||||||
|
) |
||||||
|
) |
||||||
|
) |
||||||
|
} |
||||||
|
) |
||||||
|
public boolean stopProxy() { |
||||||
|
DevProxyManager devProxyManager = DevProxyManager.getInstance(); |
||||||
|
devProxyManager.stop(); |
||||||
|
return !devProxyManager.isRunning(); |
||||||
|
} |
||||||
|
|
||||||
|
} |
@ -0,0 +1,74 @@ |
|||||||
|
package org.qortal.controller; |
||||||
|
|
||||||
|
import org.apache.logging.log4j.LogManager; |
||||||
|
import org.apache.logging.log4j.Logger; |
||||||
|
import org.qortal.api.DevProxyService; |
||||||
|
import org.qortal.repository.DataException; |
||||||
|
import org.qortal.settings.Settings; |
||||||
|
|
||||||
|
public class DevProxyManager { |
||||||
|
|
||||||
|
protected static final Logger LOGGER = LogManager.getLogger(DevProxyManager.class); |
||||||
|
|
||||||
|
private static DevProxyManager instance; |
||||||
|
|
||||||
|
private boolean running = false; |
||||||
|
|
||||||
|
private String sourceHostAndPort = "127.0.0.1:5173"; // Default for React/Vite
|
||||||
|
|
||||||
|
private DevProxyManager() { |
||||||
|
|
||||||
|
} |
||||||
|
|
||||||
|
public static DevProxyManager getInstance() { |
||||||
|
if (instance == null) |
||||||
|
instance = new DevProxyManager(); |
||||||
|
|
||||||
|
return instance; |
||||||
|
} |
||||||
|
|
||||||
|
public void start() throws DataException { |
||||||
|
synchronized(this) { |
||||||
|
if (this.running) { |
||||||
|
// Already running
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
LOGGER.info(String.format("Starting developer proxy service on port %d", Settings.getInstance().getDevProxyPort())); |
||||||
|
DevProxyService devProxyService = DevProxyService.getInstance(); |
||||||
|
devProxyService.start(); |
||||||
|
this.running = true; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void stop() { |
||||||
|
synchronized(this) { |
||||||
|
if (!this.running) { |
||||||
|
// Not running
|
||||||
|
return; |
||||||
|
} |
||||||
|
|
||||||
|
LOGGER.info(String.format("Shutting down developer proxy service")); |
||||||
|
DevProxyService devProxyService = DevProxyService.getInstance(); |
||||||
|
devProxyService.stop(); |
||||||
|
this.running = false; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
public void setSourceHostAndPort(String sourceHostAndPort) { |
||||||
|
this.sourceHostAndPort = sourceHostAndPort; |
||||||
|
} |
||||||
|
|
||||||
|
public String getSourceHostAndPort() { |
||||||
|
return this.sourceHostAndPort; |
||||||
|
} |
||||||
|
|
||||||
|
public Integer getPort() { |
||||||
|
return Settings.getInstance().getDevProxyPort(); |
||||||
|
} |
||||||
|
|
||||||
|
public boolean isRunning() { |
||||||
|
return this.running; |
||||||
|
} |
||||||
|
|
||||||
|
} |
Loading…
Reference in new issue