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