feat(web): support hosting on game port
ci/woodpecker/push/docs Pipeline was successful Details
ci/woodpecker/push/jfmod Pipeline was successful Details

This commit is contained in:
Johannes Frohnmeyer 2023-08-30 17:24:14 +02:00
parent 09466ce897
commit f54f8d59c4
Signed by: Johannes
GPG Key ID: E76429612C2929F4
45 changed files with 863 additions and 407 deletions

View File

@ -16,7 +16,7 @@ val javapoetVersion by extra("1.13.0")
jfMod {
minecraftVersion = "23w33a"
yarn("build.3")
yarn("build.7")
loaderVersion = "0.14.22"
modrinth {

View File

@ -15,4 +15,4 @@
- [libjf-data-manipulation-v0](./libjf-data-manipulation-v0.md)
- [libjf-translate-v1](./libjf-translate-v1.md)
- [libjf-unsafe-v0](./libjf-unsafe-v0.md)
- [libjf-web-v0](./libjf-web-v0.md)
- [libjf-web-v1](./libjf-web-v1.md)

View File

@ -1,7 +1,7 @@
# libjf-web-v0
libjf-web-v0 provides an HTTP web server you can use in your serverside (and technically also clientside) mods
# libjf-web-v1
libjf-web-v1 provides an HTTP web server you can use in your serverside (and technically also clientside) mods
to serve web content through a unified port.
libjf-web-v0 depends on libjf-config-core-v2 to provide its config, libjf-base, fabric-lifecycle-events-v1 and fabric-command-api-v1
libjf-web-v1 depends on libjf-config-core-v2 to provide its config, libjf-base, fabric-lifecycle-events-v1 and fabric-command-api-v1
### Getting started
Implement WebInit and register it as a libjf:web entrypoint. To enable the server, also add the following to your fabric.mod.json:

View File

@ -27,9 +27,8 @@ public class CoProcessManager implements ModInitializer {
}
private void stop() {
Iterator<CoProcess> procs = coProcesses.iterator();
while (procs.hasNext()) {
CoProcess coProcess = procs.next();
for (Iterator<CoProcess> iter = coProcesses.iterator(); iter.hasNext(); ) {
CoProcess coProcess = iter.next();
coProcess.stop();
if (coProcess instanceof Closeable cl) {
try {
@ -38,7 +37,7 @@ public class CoProcessManager implements ModInitializer {
LibJf.LOGGER.error("Could not close co-process", e);
}
}
procs.remove();
iter.remove();
}
}
}

View File

@ -1,5 +1,6 @@
package io.gitlab.jfronny.libjf.config.impl;
import io.gitlab.jfronny.libjf.config.api.v2.ConfigHolder;
import io.gitlab.jfronny.libjf.config.api.v2.ConfigInstance;
import io.gitlab.jfronny.libjf.config.api.v2.dsl.DSL;
@ -10,6 +11,7 @@ public class ConfigCore {
public static final ConfigInstance CONFIG_INSTANCE;
static {
ConfigHolder.getInstance().migrateFiles(MOD_ID);
CONFIG_INSTANCE = DSL.create(MOD_ID).register(builder -> builder
.value("watchForChanges", watchForChanges, () -> watchForChanges, b -> watchForChanges = b)
);

View File

@ -1,6 +1,7 @@
{
"schemaVersion": 1,
"id": "libjf-config-core-v2",
"provides": ["libjf-config-core-v1"],
"name": "LibJF Config",
"version": "${version}",
"authors": ["JFronny"],

View File

@ -1,10 +0,0 @@
package io.gitlab.jfronny.libjf.web.api;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.HttpRequest;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.HttpResponse;
import java.io.IOException;
public interface ContentProvider {
HttpResponse handle(HttpRequest request) throws IOException;
}

View File

@ -1,10 +0,0 @@
package io.gitlab.jfronny.libjf.web.api;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.HttpRequest;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.HttpResponse;
import java.io.IOException;
public interface SubServer {
HttpResponse handle(HttpRequest request, String[] segments) throws IOException;
}

View File

@ -1,231 +0,0 @@
/*
* This file is part of BlueMap, licensed under the MIT License (MIT).
*
* Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
* Copyright (c) contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util.bluemapcore;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class HttpRequest {
private static final Pattern REQUEST_PATTERN = Pattern.compile("^(\\w+) (\\S+) (.+)$");
private final String method;
private final String adress;
private final String version;
private final Map<String, Set<String>> header;
private final Map<String, Set<String>> headerLC;
private byte[] data;
private String path = null;
private Map<String, String> getParams = null;
private String getParamString = null;
public HttpRequest(String method, String adress, String version, Map<String, Set<String>> header) {
this.method = method;
this.adress = adress;
this.version = version;
this.header = header;
this.headerLC = new HashMap<>();
for (Entry<String, Set<String>> e : header.entrySet()){
Set<String> values = new HashSet<>();
for (String v : e.getValue()){
values.add(v.toLowerCase());
}
headerLC.put(e.getKey().toLowerCase(), values);
}
this.data = new byte[0];
}
public String getMethod() {
return method;
}
public String getAdress(){
return adress;
}
public String getVersion() {
return version;
}
public Map<String, Set<String>> getHeader() {
return header;
}
public Map<String, Set<String>> getLowercaseHeader() {
return headerLC;
}
public Set<String> getHeader(String key){
Set<String> headerValues = header.get(key);
if (headerValues == null) return Collections.emptySet();
return headerValues;
}
public Set<String> getLowercaseHeader(String key){
Set<String> headerValues = headerLC.get(key.toLowerCase());
if (headerValues == null) return Collections.emptySet();
return headerValues;
}
public String getPath() {
if (path == null) parseAdress();
return path;
}
public Map<String, String> getGETParams() {
if (getParams == null) parseAdress();
return Collections.unmodifiableMap(getParams);
}
public String getGETParamString() {
if (getParamString == null) parseAdress();
return getParamString;
}
private void parseAdress() {
String adress = this.adress;
if (adress.isEmpty()) adress = "/";
String[] adressParts = adress.split("\\?", 2);
String path = adressParts[0];
this.getParamString = adressParts.length > 1 ? adressParts[1] : "";
Map<String, String> getParams = new HashMap<>();
for (String getParam : this.getParamString.split("&")){
if (getParam.isEmpty()) continue;
String[] kv = getParam.split("=", 2);
String key = kv[0];
String value = kv.length > 1 ? kv[1] : "";
getParams.put(key, value);
}
this.path = path;
this.getParams = getParams;
}
public InputStream getData(){
return new ByteArrayInputStream(data);
}
public static HttpRequest read(InputStream in) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
List<String> header = new ArrayList<>(20);
while(header.size() < 1000){
String headerLine = readLine(reader);
if (headerLine.isEmpty()) break;
header.add(headerLine);
}
if (header.isEmpty()) throw new HttpConnection.InvalidRequestException();
Matcher m = REQUEST_PATTERN.matcher(header.remove(0));
if (!m.find()) throw new HttpConnection.InvalidRequestException();
String method = m.group(1);
if (method == null) throw new HttpConnection.InvalidRequestException();
String adress = m.group(2);
if (adress == null) throw new HttpConnection.InvalidRequestException();
String version = m.group(3);
if (version == null) throw new HttpConnection.InvalidRequestException();
Map<String, Set<String>> headerMap = new HashMap<String, Set<String>>();
for (String line : header){
if (line.trim().isEmpty()) continue;
String[] kv = line.split(":", 2);
if (kv.length < 2) continue;
Set<String> values = new HashSet<>();
if (kv[0].trim().equalsIgnoreCase("If-Modified-Since")){
values.add(kv[1].trim());
} else {
for(String v : kv[1].split(",")){
values.add(v.trim());
}
}
headerMap.put(kv[0].trim(), values);
}
HttpRequest request = new HttpRequest(method, adress, version, headerMap);
if (request.getLowercaseHeader("Transfer-Encoding").contains("chunked")){
try {
ByteArrayOutputStream dataStream = new ByteArrayOutputStream();
while (dataStream.size() < 1000000){
String hexSize = reader.readLine();
int chunkSize = Integer.parseInt(hexSize, 16);
if (chunkSize <= 0) break;
byte[] data = new byte[chunkSize];
in.read(data);
dataStream.write(data);
}
if (dataStream.size() >= 1000000) {
throw new HttpConnection.InvalidRequestException();
}
request.data = dataStream.toByteArray();
return request;
} catch (NumberFormatException ex){
return request;
}
} else {
Set<String> clSet = request.getLowercaseHeader("Content-Length");
if (clSet.isEmpty()){
return request;
} else {
try {
int cl = Integer.parseInt(clSet.iterator().next());
byte[] data = new byte[cl];
in.read(data);
request.data = data;
return request;
} catch (NumberFormatException ex){
return request;
}
}
}
}
private static String readLine(BufferedReader in) throws IOException {
String line = in.readLine();
if (line == null){
throw new HttpConnection.ConnectionClosedException();
}
return line;
}
}

View File

@ -1,13 +0,0 @@
{
"libjf-web-v0.jfconfig.title": "LibJF Web v0",
"libjf-web-v0.jfconfig.serverIp": "Server IP",
"libjf-web-v0.jfconfig.serverIp.tooltip": "The public IP/host name to send to clients",
"libjf-web-v0.jfconfig.port": "Port",
"libjf-web-v0.jfconfig.port.tooltip": "The port to host content on",
"libjf-web-v0.jfconfig.portOverride": "Port Override",
"libjf-web-v0.jfconfig.portOverride.tooltip": "The port to send to clients (for reverse proxies, -1 to disable)",
"libjf-web-v0.jfconfig.maxConnections": "Max. Connections",
"libjf-web-v0.jfconfig.maxConnections.tooltip": "The maximum number of concurrent connections to this server",
"libjf-web-v0.jfconfig.enableFileHost": "Enable File Host",
"libjf-web-v0.jfconfig.enableFileHost.tooltip": "Whether files from config/wwwroot should be hosted as static resources"
}

View File

@ -5,7 +5,7 @@ plugins {
}
base {
archivesName.set("libjf-web-v0")
archivesName.set("libjf-web-v1")
}
dependencies {

View File

@ -1,4 +1,4 @@
package io.gitlab.jfronny.libjf.web.api;
package io.gitlab.jfronny.libjf.web.api.v1;
public interface AdvancedSubServer extends SubServer {
void onStop();

View File

@ -0,0 +1,7 @@
package io.gitlab.jfronny.libjf.web.api.v1;
import java.io.IOException;
public interface ContentProvider {
HttpResponse handle(HttpRequest request) throws IOException;
}

View File

@ -0,0 +1,30 @@
package io.gitlab.jfronny.libjf.web.api.v1;
import io.gitlab.jfronny.libjf.web.impl.util.HttpRequestImpl;
import java.io.IOException;
import java.io.InputStream;
import java.util.Map;
import java.util.Set;
public interface HttpRequest {
static HttpRequest read(InputStream in) throws IOException {
return HttpRequestImpl.read(in);
}
default HttpResponse createResponse(HttpStatusCode statusCode) {
return HttpResponse.create(statusCode);
}
String getMethod();
String getAddress();
String getVersion();
Map<String, Set<String>> getHeader();
Map<String, Set<String>> getLowercaseHeader();
Set<String> getHeader(String key);
Set<String> getLowercaseHeader(String key);
String getPath();
Map<String, String> getQuery();
String getQueryString();
InputStream getData();
}

View File

@ -0,0 +1,24 @@
package io.gitlab.jfronny.libjf.web.api.v1;
import io.gitlab.jfronny.libjf.web.impl.util.HttpResponseImpl;
import java.io.*;
import java.util.Map;
import java.util.Set;
public interface HttpResponse extends Closeable {
static HttpResponse create(HttpStatusCode statusCode) {
return new HttpResponseImpl(statusCode);
}
HttpResponse addHeader(String key, String value);
HttpResponse removeHeader(String key, String value);
HttpResponse setData(InputStream data);
HttpResponse setData(String data);
void write(OutputStream out) throws IOException;
HttpStatusCode getStatusCode();
String getVersion();
Map<String, Set<String>> getHeader();
Set<String> getHeader(String key);
}

View File

@ -22,7 +22,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util.bluemapcore;
package io.gitlab.jfronny.libjf.web.api.v1;
public enum HttpStatusCode {
CONTINUE (100, "Continue"),

View File

@ -0,0 +1,7 @@
package io.gitlab.jfronny.libjf.web.api.v1;
import java.io.IOException;
public interface SubServer {
HttpResponse handle(HttpRequest request, String[] segments) throws IOException;
}

View File

@ -1,4 +1,4 @@
package io.gitlab.jfronny.libjf.web.api;
package io.gitlab.jfronny.libjf.web.api.v1;
public interface WebInit {
void register(WebServer api);

View File

@ -1,4 +1,4 @@
package io.gitlab.jfronny.libjf.web.api;
package io.gitlab.jfronny.libjf.web.api.v1;
import io.gitlab.jfronny.libjf.web.impl.JfWeb;
@ -7,14 +7,14 @@ import java.nio.file.Path;
public interface WebServer {
String register(String webPath, ContentProvider provider);
String registerFile(String webPath, Path file, Boolean readOnSend) throws IOException;
String registerFile(String webPath, Path file, boolean readOnSend) throws IOException;
String registerFile(String webPath, byte[] data, String contentType);
String registerDir(String webPath, Path dir, Boolean readOnSend) throws IOException;
String registerDir(String webPath, Path dir, boolean readOnSend) throws IOException;
String registerSubServer(String webPath, SubServer subServer);
String registerSubServer(String webPath, AdvancedSubServer subServer);
String getServerRoot();
void stop();
void restart();
void queueRestart(Runnable callback);
boolean isActive();
static WebServer getInstance() {

View File

@ -1,8 +1,8 @@
package io.gitlab.jfronny.libjf.web.impl;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.WebInit;
import io.gitlab.jfronny.libjf.web.api.WebServer;
import io.gitlab.jfronny.libjf.web.api.v1.WebInit;
import io.gitlab.jfronny.libjf.web.api.v1.WebServer;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;

View File

@ -1,31 +1,50 @@
package io.gitlab.jfronny.libjf.web.impl;
import com.mojang.brigadier.Command;
import io.gitlab.jfronny.commons.ref.R;
import io.gitlab.jfronny.libjf.Flags;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.coprocess.CoProcess;
import io.gitlab.jfronny.libjf.web.api.WebServer;
import io.gitlab.jfronny.libjf.web.api.v1.WebServer;
import io.gitlab.jfronny.libjf.web.impl.variant.hosted.HostedWebServer;
import io.gitlab.jfronny.libjf.web.impl.variant.shared.SharedWebServer;
import net.fabricmc.api.EnvType;
import net.fabricmc.api.ModInitializer;
import net.fabricmc.fabric.api.command.v2.CommandRegistrationCallback;
import net.fabricmc.loader.api.FabricLoader;
import net.minecraft.text.Text;
import org.jetbrains.annotations.ApiStatus;
import java.time.chrono.IsoEra;
import static net.minecraft.server.command.CommandManager.literal;
public class JfWeb implements CoProcess, ModInitializer {
private static final RequestHandler handler;
public static final WebServer SERVER;
static {
JfWebConfig.ensureValidPort();
SERVER = new JfWebServer(JfWebConfig.port, JfWebConfig.maxConnections);
handler = new RequestHandler();
if (JfWebConfig.port != -1) SERVER = new HostedWebServer(handler, JfWebConfig.port, JfWebConfig.maxConnections);
else if (FabricLoader.getInstance().getEnvironmentType() == EnvType.CLIENT)
SERVER = new HostedWebServer(handler, 0, JfWebConfig.maxConnections);
else SERVER = new SharedWebServer(handler);
}
@ApiStatus.Internal
public static RequestHandler getHandler() {
return handler;
}
@Override
public void start() {
if (isEnabled()) SERVER.restart();
if (isEnabled()) SERVER.queueRestart(R::nop);
}
@Override
public void stop() {
if (isEnabled()) SERVER.stop();
if (!(SERVER instanceof SharedWebServer)) SERVER.stop();
}
@Override
@ -43,7 +62,9 @@ public class JfWeb implements CoProcess, ModInitializer {
}).then(literal("restart").executes(context -> {
try {
context.getSource().sendFeedback(() -> Text.literal("Restarting LibWeb"), true);
SERVER.restart();
SERVER.queueRestart(() -> {
context.getSource().sendFeedback(() -> Text.literal("LibWeb restarted"), true);
});
}
catch (Exception e) {
LibJf.LOGGER.error("Failed to run restart command", e);
@ -53,7 +74,6 @@ public class JfWeb implements CoProcess, ModInitializer {
}))));
});
}
Runtime.getRuntime().addShutdownHook(new Thread(SERVER::stop));
}
private boolean isEnabled() {

View File

@ -9,19 +9,24 @@ import java.net.ServerSocket;
@JfConfig
public class JfWebConfig {
@Entry public static String serverIp = "http://127.0.0.1";
@Entry(min = 0, max = 35535) public static int port = 0;
@Entry(min = -1, max = 35535) public static int port = 0;
@Entry(min = -1, max = 35535) public static int portOverride = -1;
@Entry(min = 8, max = 64) public static int maxConnections = 20;
@Entry public static boolean enableFileHost = false;
public static void ensureValidPort() {
if (port == 0) {
try (ServerSocket socket = new ServerSocket(0)) {
port = socket.getLocalPort();
} catch (IOException e) {
LibJf.LOGGER.error("Could not bind port to identify available", e);
}
ConfigHolder.getInstance().getRegistered().get("libjf-web-v0").write();
port = findAvailablePort();
ConfigHolder.getInstance().getRegistered().get("libjf-web-v1").write();
}
}
public static int findAvailablePort() {
try (ServerSocket socket = new ServerSocket(0)) {
return socket.getLocalPort();
} catch (IOException e) {
LibJf.LOGGER.error("Could not bind port to identify available", e);
return 0;
}
}

View File

@ -1,10 +1,9 @@
package io.gitlab.jfronny.libjf.web.impl;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.AdvancedSubServer;
import io.gitlab.jfronny.libjf.web.api.ContentProvider;
import io.gitlab.jfronny.libjf.web.impl.util.WebPaths;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.*;
import io.gitlab.jfronny.libjf.web.api.v1.*;
import io.gitlab.jfronny.libjf.web.impl.util.*;
import io.gitlab.jfronny.libjf.web.impl.variant.hosted.HttpRequestHandler;
import java.util.*;
@ -17,7 +16,7 @@ public class RequestHandler implements HttpRequestHandler {
HttpResponse resp = null;
try {
String webPath = WebPaths.simplify(request.getPath());
if (webPath.length() == 0)
if (webPath.isEmpty())
webPath = "index.html";
if (contentProviders.containsKey(webPath)) {
resp = contentProviders.get(webPath).handle(request);
@ -32,16 +31,16 @@ public class RequestHandler implements HttpRequestHandler {
}
}
if (resp == null) {
resp = new HttpResponse(HttpStatusCode.NOT_FOUND);
resp = new HttpResponseImpl(HttpStatusCode.NOT_FOUND);
}
}
} catch (Throwable e) {
LibJf.LOGGER.error("Caught error while sending", e);
resp = new HttpResponse(HttpStatusCode.INTERNAL_SERVER_ERROR);
resp = new HttpResponseImpl(HttpStatusCode.INTERNAL_SERVER_ERROR);
}
if (resp.getHeader("Cache-Control").size() == 0)
if (resp.getHeader("Cache-Control").isEmpty())
resp.addHeader("Cache-Control", "no-cache");
if (resp.getHeader("Server").size() == 0)
if (resp.getHeader("Server").isEmpty())
resp.addHeader("Server", "LibWeb using BlueMapCore");
return resp;
}

View File

@ -0,0 +1,47 @@
package io.gitlab.jfronny.libjf.web.impl.mixin;
import io.gitlab.jfronny.libjf.web.impl.JfWebConfig;
import org.objectweb.asm.tree.ClassNode;
import org.spongepowered.asm.mixin.extensibility.IMixinConfigPlugin;
import org.spongepowered.asm.mixin.extensibility.IMixinInfo;
import java.util.List;
import java.util.Set;
public class JfWebMixinPlugin implements IMixinConfigPlugin {
private static final String MIXIN_PACKAGE = "io.gitlab.jfronny.libjf.web.impl.mixin.";
@Override
public void onLoad(String mixinPackage) {
}
@Override
public String getRefMapperConfig() {
return null;
}
@Override
public boolean shouldApplyMixin(String targetClassName, String mixinClassName) {
return switch (mixinClassName) {
case MIXIN_PACKAGE + "ServerNetworkIoMixin", MIXIN_PACKAGE + "ServerNetworkIo$1Mixin" -> JfWebConfig.port == -1;
default -> throw new IllegalArgumentException("Unexpected mixin: " + mixinClassName);
};
}
@Override
public void acceptTargets(Set<String> myTargets, Set<String> otherTargets) {
}
@Override
public List<String> getMixins() {
return null;
}
@Override
public void preApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
}
@Override
public void postApply(String targetClassName, ClassNode targetClass, String mixinClassName, IMixinInfo mixinInfo) {
}
}

View File

@ -0,0 +1,16 @@
package io.gitlab.jfronny.libjf.web.impl.mixin;
import io.gitlab.jfronny.libjf.web.impl.variant.shared.HttpDecoder;
import io.netty.channel.Channel;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
@Mixin(targets = "net.minecraft.server.ServerNetworkIo$1")
public class ServerNetworkIo$1Mixin {
@Inject(method = "initChannel(Lio/netty/channel/Channel;)V", at = @At(value = "INVOKE", target = "Lnet/minecraft/network/ClientConnection;addHandlers(Lio/netty/channel/ChannelPipeline;Lnet/minecraft/network/NetworkSide;Lnet/minecraft/network/handler/PacketSizeLogger;)V"))
private void inject(Channel channel, CallbackInfo ci) {
channel.pipeline().addAfter("legacy_query", "libjf_http", new HttpDecoder());
}
}

View File

@ -0,0 +1,30 @@
package io.gitlab.jfronny.libjf.web.impl.mixin;
import io.gitlab.jfronny.libjf.web.impl.util.ClaimPool;
import io.gitlab.jfronny.libjf.web.impl.variant.shared.SharedWebServer;
import net.minecraft.server.ServerNetworkIo;
import org.spongepowered.asm.mixin.Mixin;
import org.spongepowered.asm.mixin.Unique;
import org.spongepowered.asm.mixin.injection.At;
import org.spongepowered.asm.mixin.injection.Inject;
import org.spongepowered.asm.mixin.injection.callback.CallbackInfo;
import java.net.InetAddress;
import java.util.HashSet;
import java.util.Set;
@Mixin(ServerNetworkIo.class)
public class ServerNetworkIoMixin {
@Unique private final Set<ClaimPool<Integer>.Claim> libjf$portClaim = new HashSet<>();
@Inject(method = "bind(Ljava/net/InetAddress;I)V", at = @At("HEAD"))
void onBind(InetAddress address, int port, CallbackInfo ci) {
libjf$portClaim.add(SharedWebServer.gamePort.claim(port));
SharedWebServer.emitActive();
}
@Inject(method = "stop()V", at = @At("HEAD"))
void onStop(CallbackInfo ci) {
for (ClaimPool<Integer>.Claim claim : libjf$portClaim) claim.release();
}
}

View File

@ -0,0 +1,37 @@
package io.gitlab.jfronny.libjf.web.impl.util;
import java.util.LinkedList;
import java.util.List;
import java.util.concurrent.atomic.AtomicBoolean;
public class ClaimPool<T> {
private final List<Claim> content = new LinkedList<>();
public Claim claim(T value) {
return new Claim(value);
}
public T getTopmost() {
return content.isEmpty() ? null : content.get(content.size() - 1).value;
}
public boolean isEmpty() {
return content.isEmpty();
}
public class Claim {
private final T value;
private final AtomicBoolean active = new AtomicBoolean(true);
private Claim(T value) {
this.value = value;
content.add(this);
}
public void release() {
if (!active.getAndSet(false))
throw new UnsupportedOperationException("Cannot release claim that is already released");
content.remove(this);
}
}
}

View File

@ -0,0 +1,249 @@
/*
* This file is part of BlueMap, licensed under the MIT License (MIT).
*
* Copyright (c) Blue (Lukas Rieger) <https://bluecolored.de>
* Copyright (c) contributors
*
* Permission is hereby granted, free of charge, to any person obtaining a copy
* of this software and associated documentation files (the "Software"), to deal
* in the Software without restriction, including without limitation the rights
* to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
* copies of the Software, and to permit persons to whom the Software is
* furnished to do so, subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in
* all copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
* FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
* AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
* LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util;
import io.gitlab.jfronny.libjf.web.api.v1.*;
import io.gitlab.jfronny.libjf.web.impl.variant.hosted.HttpConnection;
import java.io.*;
import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Map.Entry;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
public class HttpRequestImpl implements HttpRequest {
private static final Pattern REQUEST_PATTERN = Pattern.compile("^(\\w+) (\\S+) (.+)$");
private final String method;
private final String address;
private final String version;
private final Map<String, Set<String>> header;
private final Map<String, Set<String>> headerLC;
private byte[] data;
private String path = null;
private Map<String, String> queryParameters = null;
private String queryString = null;
public HttpRequestImpl(String method, String address, String version, Map<String, Set<String>> header) {
this.method = method;
this.address = address;
this.version = version;
this.header = header;
this.headerLC = new HashMap<>();
for (Entry<String, Set<String>> e : header.entrySet()){
Set<String> values = new HashSet<>();
for (String v : e.getValue()){
values.add(v.toLowerCase());
}
headerLC.put(e.getKey().toLowerCase(), values);
}
this.data = new byte[0];
}
@Override
public String getMethod() {
return method;
}
@Override
public String getAddress(){
return address;
}
@Override
public String getVersion() {
return version;
}
@Override
public Map<String, Set<String>> getHeader() {
return header;
}
@Override
public Map<String, Set<String>> getLowercaseHeader() {
return headerLC;
}
@Override
public Set<String> getHeader(String key){
Set<String> headerValues = header.get(key);
if (headerValues == null) return Collections.emptySet();
return headerValues;
}
@Override
public Set<String> getLowercaseHeader(String key){
Set<String> headerValues = headerLC.get(key.toLowerCase());
if (headerValues == null) return Collections.emptySet();
return headerValues;
}
@Override
public String getPath() {
if (path == null) parseAddress();
return path;
}
@Override
public Map<String, String> getQuery() {
if (queryParameters == null) parseAddress();
return Collections.unmodifiableMap(queryParameters);
}
@Override
public String getQueryString() {
if (queryString == null) parseAddress();
return queryString;
}
private void parseAddress() {
String adress = this.address;
if (adress.isEmpty()) adress = "/";
String[] addressParts = adress.split("\\?", 2);
String path = addressParts[0];
this.queryString = addressParts.length > 1 ? addressParts[1] : "";
Map<String, String> queryParams = new HashMap<>();
for (String queryParam : this.queryString.split("&")){
if (queryParam.isEmpty()) continue;
String[] kv = queryParam.split("=", 2);
String key = kv[0];
String value = kv.length > 1 ? kv[1] : "";
queryParams.put(key, value);
}
this.path = path;
this.queryParameters = queryParams;
}
@Override
public InputStream getData(){
return new ByteArrayInputStream(data);
}
public static HttpRequestImpl read(InputStream in) throws IOException {
BufferedReader reader = new BufferedReader(new InputStreamReader(in, StandardCharsets.UTF_8));
HttpRequestImpl request = fromHeaders(extractHeaders(reader));
readData(in, request, reader);
return request;
}
private static void readData(InputStream in, HttpRequestImpl request, BufferedReader reader) throws IOException {
if (request.getLowercaseHeader("Transfer-Encoding").contains("chunked")) {
try {
ByteArrayOutputStream dataStream = new ByteArrayOutputStream();
while (dataStream.size() < 1000000) {
String hexSize = reader.readLine();
int chunkSize = Integer.parseInt(hexSize, 16);
if (chunkSize <= 0) break;
byte[] data = new byte[chunkSize];
in.read(data);
dataStream.write(data);
}
if (dataStream.size() >= 1000000) {
throw new HttpConnection.InvalidRequestException();
}
request.data = dataStream.toByteArray();
} catch (NumberFormatException ex) {
}
} else {
Set<String> clSet = request.getLowercaseHeader("Content-Length");
if (clSet.isEmpty()) return;
try {
int cl = Integer.parseInt(clSet.iterator().next());
byte[] data = new byte[cl];
in.read(data);
request.data = data;
} catch (NumberFormatException ex) {
}
}
}
private static List<String> extractHeaders(BufferedReader reader) throws IOException {
List<String> headers = new ArrayList<>(20);
while (headers.size() < 1000) {
String headerLine = readLine(reader);
if (headerLine.isEmpty()) break;
headers.add(headerLine);
}
return headers;
}
private static HttpRequestImpl fromHeaders(List<String> headers) throws HttpConnection.InvalidRequestException {
if (headers.isEmpty()) throw new HttpConnection.InvalidRequestException();
Matcher m = REQUEST_PATTERN.matcher(headers.remove(0));
if (!m.find()) throw new HttpConnection.InvalidRequestException();
String method = m.group(1);
if (method == null) throw new HttpConnection.InvalidRequestException();
String adress = m.group(2);
if (adress == null) throw new HttpConnection.InvalidRequestException();
String version = m.group(3);
if (version == null) throw new HttpConnection.InvalidRequestException();
return new HttpRequestImpl(method, adress, version, parseExtraHeaders(headers));
}
private static Map<String, Set<String>> parseExtraHeaders(List<String> header) {
Map<String, Set<String>> headerMap = new HashMap<>();
for (String line : header) {
if (line.trim().isEmpty()) continue;
String[] kv = line.split(":", 2);
if (kv.length < 2) continue;
Set<String> values = new HashSet<>();
if (kv[0].trim().equalsIgnoreCase("If-Modified-Since")) {
values.add(kv[1].trim());
} else {
for (String v : kv[1].split(",")) {
values.add(v.trim());
}
}
headerMap.put(kv[0].trim(), values);
}
return headerMap;
}
private static String readLine(BufferedReader in) throws IOException {
String line = in.readLine();
if (line == null) {
throw new HttpConnection.ConnectionClosedException();
}
return line;
}
}

View File

@ -22,8 +22,10 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util.bluemapcore;
package io.gitlab.jfronny.libjf.web.impl.util;
import io.gitlab.jfronny.libjf.web.api.v1.HttpResponse;
import io.gitlab.jfronny.libjf.web.api.v1.HttpStatusCode;
import org.apache.commons.lang3.StringUtils;
import java.io.*;
@ -31,13 +33,14 @@ import java.nio.charset.StandardCharsets;
import java.util.*;
import java.util.Map.Entry;
public class HttpResponse implements Closeable {
public class HttpResponseImpl implements HttpResponse {
private final String version;
private final HttpStatusCode statusCode;
private final Map<String, Set<String>> header;
private boolean closed = false;
private InputStream data;
public HttpResponse(HttpStatusCode statusCode) {
public HttpResponseImpl(HttpStatusCode statusCode) {
this.version = "HTTP/1.1";
this.statusCode = statusCode;
@ -46,24 +49,32 @@ public class HttpResponse implements Closeable {
addHeader("Connection", "keep-alive");
}
public HttpResponse addHeader(String key, String value){
@Override
public HttpResponseImpl addHeader(String key, String value) {
ensureOpen();
Set<String> valueSet = header.computeIfAbsent(key, k -> new HashSet<>());
valueSet.add(value);
return this;
}
public HttpResponse removeHeader(String key, String value){
@Override
public HttpResponseImpl removeHeader(String key, String value) {
ensureOpen();
Set<String> valueSet = header.computeIfAbsent(key, k -> new HashSet<>());
valueSet.remove(value);
return this;
}
public HttpResponse setData(InputStream dataStream){
@Override
public HttpResponseImpl setData(InputStream dataStream) {
ensureOpen();
this.data = dataStream;
return this;
}
public HttpResponse setData(String data){
@Override
public HttpResponseImpl setData(String data) {
ensureOpen();
setData(new ByteArrayInputStream(data.getBytes(StandardCharsets.UTF_8)));
return this;
}
@ -73,17 +84,18 @@ public class HttpResponse implements Closeable {
* <br>
* This method closes the data-Stream of this response so it can't be used again!
*/
public HttpResponse write(OutputStream out) throws IOException {
@Override
public void write(OutputStream out) throws IOException {
OutputStreamWriter writer = new OutputStreamWriter(out, StandardCharsets.UTF_8);
if (data != null){
if (data != null) {
addHeader("Transfer-Encoding", "chunked");
} else {
addHeader("Content-Length", "0");
}
writeLine(writer, version + " " + statusCode.getCode() + " " + statusCode.getMessage());
for (Entry<String, Set<String>> e : header.entrySet()){
for (Entry<String, Set<String>> e : header.entrySet()) {
if (e.getValue().isEmpty()) continue;
writeLine(writer, e.getKey() + ": " + StringUtils.join(e.getValue(), ", "));
}
@ -91,17 +103,20 @@ public class HttpResponse implements Closeable {
writeLine(writer, "");
writer.flush();
if(data != null){
if (data != null) {
boolean markSupported = data.markSupported();
if (markSupported) data.mark(Integer.MAX_VALUE);
chunkedPipe(data, out);
out.flush();
data.close();
if (markSupported) data.reset();
else close();
}
return this;
}
@Override
public void close() throws IOException {
data.close();
if (data != null) data.close();
this.closed = true;
}
private void writeLine(OutputStreamWriter writer, String line) throws IOException {
@ -119,18 +134,26 @@ public class HttpResponse implements Closeable {
output.write("0\r\n\r\n".getBytes());
}
private void ensureOpen() {
if (closed) throw new UnsupportedOperationException("Response cannot be changed after being closed or written");
}
@Override
public HttpStatusCode getStatusCode(){
return statusCode;
}
@Override
public String getVersion(){
return version;
}
@Override
public Map<String, Set<String>> getHeader() {
return header;
}
@Override
public Set<String> getHeader(String key){
Set<String> headerValues = header.get(key);
if (headerValues == null) return Collections.emptySet();

View File

@ -1,5 +1,7 @@
package io.gitlab.jfronny.libjf.web.impl.util;
import io.gitlab.jfronny.libjf.web.impl.JfWebConfig;
public class WebPaths {
public static String concat(String s1, String s2) {
return simplify(s1) + "/" + simplify(s2);
@ -13,6 +15,12 @@ public class WebPaths {
return simplify(s.toString());
}
public static String getHttp(String ip) {
if (!ip.startsWith("http"))
ip = "http://" + ip;
return simplify(ip);
}
public static String simplify(String s) {
boolean http = false;
boolean https = false;

View File

@ -1,10 +1,11 @@
package io.gitlab.jfronny.libjf.web.impl;
package io.gitlab.jfronny.libjf.web.impl.variant;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.*;
import io.gitlab.jfronny.libjf.web.impl.util.WebPaths;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.*;
import io.gitlab.jfronny.libjf.web.api.v1.*;
import io.gitlab.jfronny.libjf.web.impl.*;
import io.gitlab.jfronny.libjf.web.impl.util.*;
import net.fabricmc.loader.api.FabricLoader;
import org.jetbrains.annotations.ApiStatus;
import javax.management.openmbean.KeyAlreadyExistsException;
import java.io.*;
@ -13,15 +14,17 @@ import java.nio.file.Path;
import java.util.Objects;
import java.util.stream.Stream;
public class JfWebServer implements WebServer {
private HttpServer server = null;
private final RequestHandler handler = new RequestHandler();
private final int port;
private final int maxConnections;
public abstract class AbstractWebServer implements WebServer {
protected final RequestHandler handler;
private final DefaultFileHost dfh = new DefaultFileHost();
public JfWebServer(int port, int maxConnections) {
this.port = port;
this.maxConnections = maxConnections;
protected AbstractWebServer(RequestHandler handler) {
this.handler = handler;
}
@ApiStatus.Internal
public RequestHandler getHandler() {
return handler;
}
@Override
@ -34,12 +37,12 @@ public class JfWebServer implements WebServer {
}
@Override
public String registerFile(String webPath, Path file, Boolean readOnSend) throws IOException {
public String registerFile(String webPath, Path file, boolean readOnSend) throws IOException {
if (readOnSend) {
if (!Files.exists(file))
throw new FileNotFoundException();
register(webPath, s -> {
HttpResponse resp = new HttpResponse(HttpStatusCode.OK);
HttpResponse resp = HttpResponse.create(HttpStatusCode.OK);
resp.addHeader("Content-Type", Files.probeContentType(file));
resp.addHeader("Content-Length", String.valueOf(Files.size(file)));
FileInputStream fs = new FileInputStream(file.toFile());
@ -56,7 +59,7 @@ public class JfWebServer implements WebServer {
@Override
public String registerFile(String webPath, byte[] data, String contentType) {
return register(webPath, s -> {
HttpResponse resp = new HttpResponse(HttpStatusCode.OK);
HttpResponse resp = HttpResponse.create(HttpStatusCode.OK);
resp.addHeader("Content-Type", contentType);
resp.addHeader("Content-Length", String.valueOf(data.length));
ByteArrayInputStream fs = new ByteArrayInputStream(data);
@ -66,7 +69,7 @@ public class JfWebServer implements WebServer {
}
@Override
public String registerDir(String webPath, Path dir, Boolean readOnSend) throws IOException {
public String registerDir(String webPath, Path dir, boolean readOnSend) throws IOException {
try (Stream<Path> contentPath = Files.walk(dir)) {
if (readOnSend) {
return registerSubServer(webPath, (s, t) -> {
@ -89,13 +92,13 @@ public class JfWebServer implements WebServer {
});
HttpResponse resp;
if (c[0]) {
resp = new HttpResponse(HttpStatusCode.OK);
resp = HttpResponse.create(HttpStatusCode.OK);
resp.addHeader("Content-Type", Files.probeContentType(p_f[0]));
resp.addHeader("Content-Length", String.valueOf(Files.size(p_f[0])));
FileInputStream fs = new FileInputStream(p_f[0].toFile());
resp.setData(fs);
} else {
resp = new HttpResponse(HttpStatusCode.NOT_FOUND);
resp = HttpResponse.create(HttpStatusCode.NOT_FOUND);
}
return resp;
});
@ -122,12 +125,10 @@ public class JfWebServer implements WebServer {
return registerSubServer(webPath, new AdvancedSubServer() {
@Override
public void onStop() {
}
@Override
public void onStart() {
}
@Override
@ -146,61 +147,16 @@ public class JfWebServer implements WebServer {
return WebPaths.concat(getServerRoot(), webPath);
}
@Override
public String getServerRoot() {
String ip = JfWebConfig.serverIp;
if (!ip.startsWith("http"))
ip = "http://" + ip;
if (JfWebConfig.portOverride != -1) {
return WebPaths.simplify(ip) + ":" + JfWebConfig.portOverride;
}
return WebPaths.simplify(ip) + ":" + JfWebConfig.port;
}
@Override
public synchronized void stop() {
for (AdvancedSubServer subServer : handler.subServers.values()) subServer.onStop();
if (server != null) {
try {
server.close();
server.join();
}
catch (InterruptedException e) {
//It is most likely already dead
}
}
}
@Override
public synchronized void restart() {
JfWebConfig.ensureValidPort();
int tmpPort = port;
if (server != null) {
tmpPort = server.getPort();
stop();
}
handler.clear();
server = new HttpServer(null, tmpPort, maxConnections, handler, () -> {
if (JfWebConfig.enableFileHost) dfh.register(this);
FabricLoader.getInstance().getEntrypointContainers(LibJf.MOD_ID + ":web", WebInit.class).forEach(entrypoint -> {
WebInit init = entrypoint.getEntrypoint();
init.register(this);
});
protected void performRegistrations() {
if (JfWebConfig.enableFileHost) dfh.register(this);
FabricLoader.getInstance().getEntrypointContainers(LibJf.MOD_ID + ":web", WebInit.class).forEach(entrypoint -> {
WebInit init = entrypoint.getEntrypoint();
init.register(this);
});
server.start();
try {
server.waitUntilReady();
} catch (InterruptedException e) {
stop();
LibJf.LOGGER.error("Server could not be readied", e);
}
for (AdvancedSubServer subServer : handler.subServers.values()) {
subServer.onStart();
}
}
@Override
public boolean isActive() {
return server != null && server.isAlive();
protected String getServerRoot(int hostedPort) {
return WebPaths.getHttp(JfWebConfig.serverIp) + ":"
+ (JfWebConfig.portOverride != -1 ? JfWebConfig.portOverride : hostedPort);
}
}

View File

@ -0,0 +1,64 @@
package io.gitlab.jfronny.libjf.web.impl.variant.hosted;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.v1.AdvancedSubServer;
import io.gitlab.jfronny.libjf.web.impl.*;
import io.gitlab.jfronny.libjf.web.impl.variant.AbstractWebServer;
public class HostedWebServer extends AbstractWebServer {
private HttpServer server = null;
private final int port;
private final int maxConnections;
public HostedWebServer(RequestHandler handler, int port, int maxConnections) {
super(handler);
this.port = port;
this.maxConnections = maxConnections;
}
@Override
public String getServerRoot() {
return getServerRoot(server.getPort());
}
@Override
public synchronized void stop() {
for (AdvancedSubServer subServer : handler.subServers.values()) subServer.onStop();
if (server != null) {
try {
server.close();
server.join();
}
catch (InterruptedException e) {
//It is most likely already dead
}
}
}
@Override
public void queueRestart(Runnable callback) {
int tmpPort = port;
if (server != null) {
tmpPort = server.getPort();
stop();
} else if (tmpPort == 0) tmpPort = JfWebConfig.findAvailablePort();
handler.clear();
server = new HttpServer(null, tmpPort, maxConnections, handler, this::performRegistrations);
server.start();
try {
server.waitUntilReady();
} catch (InterruptedException e) {
stop();
LibJf.LOGGER.error("Server could not be readied", e);
}
for (AdvancedSubServer subServer : handler.subServers.values()) {
subServer.onStart();
}
callback.run();
}
@Override
public boolean isActive() {
return server != null && server.isAlive();
}
}

View File

@ -22,9 +22,12 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util.bluemapcore;
package io.gitlab.jfronny.libjf.web.impl.variant.hosted;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.v1.*;
import io.gitlab.jfronny.libjf.web.impl.util.HttpRequestImpl;
import io.gitlab.jfronny.libjf.web.impl.util.HttpResponseImpl;
import java.io.*;
import java.net.*;
@ -64,8 +67,9 @@ public class HttpConnection implements Runnable {
sendResponse(response);
} catch (InvalidRequestException e){
try {
sendResponse(new HttpResponse(HttpStatusCode.BAD_REQUEST));
} catch (IOException e1) {}
sendResponse(HttpResponse.create(HttpStatusCode.BAD_REQUEST));
} catch (IOException e1) {
}
break;
} catch (SocketTimeoutException | SocketException | ConnectionClosedException e) {
break;
@ -82,7 +86,7 @@ public class HttpConnection implements Runnable {
}
}
private void log(HttpRequest request, HttpResponse response) {
private void log(HttpRequestImpl request, HttpResponseImpl response) {
DateFormat dateFormat = new SimpleDateFormat("yyyy/MM/dd HH:mm:ss");
Date date = new Date();
LibJf.LOGGER.info(

View File

@ -22,7 +22,12 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util.bluemapcore;
package io.gitlab.jfronny.libjf.web.impl.variant.hosted;
import io.gitlab.jfronny.libjf.web.api.v1.HttpRequest;
import io.gitlab.jfronny.libjf.web.api.v1.HttpResponse;
import io.gitlab.jfronny.libjf.web.impl.util.HttpRequestImpl;
import io.gitlab.jfronny.libjf.web.impl.util.HttpResponseImpl;
@FunctionalInterface
public interface HttpRequestHandler {

View File

@ -22,7 +22,7 @@
* OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
* THE SOFTWARE.
*/
package io.gitlab.jfronny.libjf.web.impl.util.bluemapcore;
package io.gitlab.jfronny.libjf.web.impl.variant.hosted;
import io.gitlab.jfronny.libjf.LibJf;

View File

@ -0,0 +1,68 @@
package io.gitlab.jfronny.libjf.web.impl.variant.shared;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.v1.HttpRequest;
import io.gitlab.jfronny.libjf.web.api.v1.HttpResponse;
import io.gitlab.jfronny.libjf.web.impl.JfWeb;
import io.gitlab.jfronny.libjf.web.impl.RequestHandler;
import io.gitlab.jfronny.libjf.web.impl.util.HttpRequestImpl;
import io.gitlab.jfronny.libjf.web.impl.util.HttpResponseImpl;
import io.netty.buffer.ByteBuf;
import io.netty.buffer.Unpooled;
import io.netty.channel.*;
import org.jetbrains.annotations.NotNull;
import java.io.ByteArrayInputStream;
import java.io.ByteArrayOutputStream;
import java.util.List;
public class HttpDecoder extends ChannelInboundHandlerAdapter {
private static final Trie<String> METHOD = Trie.of(List.of(
"GET",
"HEAD",
"POST",
"PUT",
"PATCH",
"DELETE",
"OPTIONS", "TRACE", "CONNECT"
));
@Override
public void channelRead(@NotNull ChannelHandlerContext ctx, @NotNull Object msg) throws Exception {
ByteBuf buf = (ByteBuf) msg;
buf.markReaderIndex();
boolean passOn = true;
try {
Trie<String> current = METHOD;
while (buf.isReadable()
&& current != null
&& current.content == null)
current = current.next.get((char) buf.readByte());
if (current == null || current.content == null) return;
buf.resetReaderIndex();
byte[] data = new byte[buf.readableBytes()];
buf.readBytes(data);
try (ByteArrayInputStream is = new ByteArrayInputStream(data);
HttpResponse response = JfWeb.getHandler().handle(HttpRequest.read(is));
ByteArrayOutputStream os = new ByteArrayOutputStream()) {
response.write(os);
os.flush();
ctx.pipeline()
.firstContext()
.writeAndFlush(Unpooled.wrappedBuffer(os.toByteArray()))
.addListener(ChannelFutureListener.CLOSE);
}
buf.release();
passOn = false;
} catch (RuntimeException re) {
LibJf.LOGGER.error("Could not process HTTP", re);
} finally {
if (passOn) {
buf.resetReaderIndex();
ctx.channel().pipeline().remove(this);
ctx.fireChannelRead(msg);
}
}
}
}

View File

@ -0,0 +1,51 @@
package io.gitlab.jfronny.libjf.web.impl.variant.shared;
import io.gitlab.jfronny.libjf.web.api.v1.AdvancedSubServer;
import io.gitlab.jfronny.libjf.web.impl.*;
import io.gitlab.jfronny.libjf.web.impl.util.ClaimPool;
import io.gitlab.jfronny.libjf.web.impl.variant.AbstractWebServer;
import java.util.LinkedHashSet;
import java.util.Set;
public class SharedWebServer extends AbstractWebServer {
public static final ClaimPool<Integer> gamePort = new ClaimPool<>();
public static final Set<Runnable> onActive = new LinkedHashSet<>();
public static void emitActive() {
for (Runnable runnable : onActive) runnable.run();
}
public SharedWebServer(RequestHandler handler) {
super(handler);
}
@Override
public String getServerRoot() {
Integer gamePort = this.gamePort.getTopmost();
if (gamePort == null) throw new UnsupportedOperationException("Attempted to get server root on unhosted server");
else return getServerRoot(gamePort);
}
@Override
public void stop() {
throw new UnsupportedOperationException("A shared server cannot be stopped");
}
@Override
public void queueRestart(Runnable callback) {
onActive.add(() -> {
for (AdvancedSubServer subServer : handler.subServers.values()) subServer.onStop();
handler.clear();
performRegistrations();
for (AdvancedSubServer subServer : handler.subServers.values()) subServer.onStart();
callback.run();
});
if (isActive()) emitActive();
}
@Override
public boolean isActive() {
return !gamePort.isEmpty();
}
}

View File

@ -0,0 +1,38 @@
package io.gitlab.jfronny.libjf.web.impl.variant.shared;
import it.unimi.dsi.fastutil.chars.Char2ObjectArrayMap;
import it.unimi.dsi.fastutil.chars.Char2ObjectMap;
import java.util.List;
import java.util.Map;
import java.util.function.Function;
import java.util.stream.Collectors;
public class Trie<T> {
public final Char2ObjectMap<Trie<T>> next;
public T content;
public Trie() {
this.next = new Char2ObjectArrayMap<>();
}
public void add(Map<String, T> next) {
next.forEach(this::add);
}
public void add(String key, T value) {
if (key.isEmpty()) this.content = value;
else this.next.computeIfAbsent(key.charAt(0), k -> new Trie<>())
.add(key.substring(1), value);
}
public static <T> Trie<T> of(Map<String, T> source) {
Trie<T> root = new Trie<>();
root.add(source);
return root;
}
public static Trie<String> of(List<String> source) {
return of(source.stream().collect(Collectors.toMap(Function.identity(), Function.identity())));
}
}

View File

@ -0,0 +1,13 @@
{
"libjf-web-v1.jfconfig.title": "LibJF Web v0",
"libjf-web-v1.jfconfig.serverIp": "Server IP",
"libjf-web-v1.jfconfig.serverIp.tooltip": "The public IP/host name to send to clients",
"libjf-web-v1.jfconfig.port": "Port",
"libjf-web-v1.jfconfig.port.tooltip": "The port to host content on, 0 to choose a random one or -1 to reuse the minecraft port on servers (requires restart)",
"libjf-web-v1.jfconfig.portOverride": "Port Override",
"libjf-web-v1.jfconfig.portOverride.tooltip": "The port to send to clients (for reverse proxies, -1 to disable)",
"libjf-web-v1.jfconfig.maxConnections": "Max. Connections",
"libjf-web-v1.jfconfig.maxConnections.tooltip": "The maximum number of concurrent connections to this server",
"libjf-web-v1.jfconfig.enableFileHost": "Enable File Host",
"libjf-web-v1.jfconfig.enableFileHost.tooltip": "Whether files from config/wwwroot should be hosted as static resources"
}

View File

@ -1,6 +1,7 @@
{
"schemaVersion": 1,
"id": "libjf-web-v0",
"id": "libjf-web-v1",
"provides": ["libjf-web-v0"],
"name": "LibJF Web",
"version": "${version}",
"authors": [
@ -14,6 +15,7 @@
},
"license": "MIT",
"environment": "*",
"mixins": ["libjf-web-v1.mixins.json"],
"entrypoints": {
"main": ["io.gitlab.jfronny.libjf.web.impl.JfWeb"],
"libjf:coprocess": ["io.gitlab.jfronny.libjf.web.impl.JfWeb"],

View File

@ -0,0 +1,14 @@
{
"required": true,
"minVersion": "0.8",
"package": "io.gitlab.jfronny.libjf.web.impl.mixin",
"compatibilityLevel": "JAVA_16",
"plugin": "io.gitlab.jfronny.libjf.web.impl.mixin.JfWebMixinPlugin",
"server": [
"ServerNetworkIo$1Mixin",
"ServerNetworkIoMixin"
],
"injectors": {
"defaultRequire": 1
}
}

View File

@ -1,10 +1,8 @@
package io.gitlab.jfronny.libjf.web.test;
import io.gitlab.jfronny.libjf.LibJf;
import io.gitlab.jfronny.libjf.web.api.WebInit;
import io.gitlab.jfronny.libjf.web.api.WebServer;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.HttpResponse;
import io.gitlab.jfronny.libjf.web.impl.util.bluemapcore.HttpStatusCode;
import io.gitlab.jfronny.libjf.web.api.v1.*;
import io.gitlab.jfronny.libjf.web.impl.util.HttpResponseImpl;
import net.fabricmc.loader.api.FabricLoader;
import java.io.IOException;
@ -14,8 +12,11 @@ import java.nio.file.Path;
public class WebTest implements WebInit {
@Override
public void register(WebServer api) {
Path sourcePath = FabricLoader.getInstance().getModContainer("libjf-web-v0-testmod").get().findPath("test.html").get();
LibJf.LOGGER.info(api.register("/test/0.html", request -> new HttpResponse(HttpStatusCode.OK).setData(Files.readString(sourcePath))));
Path sourcePath = FabricLoader.getInstance()
.getModContainer("libjf-web-v1-testmod")
.flatMap(modContainer -> modContainer.findPath("test.html"))
.orElseThrow();
LibJf.LOGGER.info(api.register("/test/0.html", request -> request.createResponse(HttpStatusCode.OK).setData(Files.readString(sourcePath))));
try {
LibJf.LOGGER.info(api.registerFile("/test/1.html", sourcePath, false));
LibJf.LOGGER.info(api.registerFile("/test/2.html", sourcePath, true));

View File

@ -1,6 +1,6 @@
{
"schemaVersion": 1,
"id": "libjf-web-v0-testmod",
"id": "libjf-web-v1-testmod",
"name": "LibJF Web",
"version": "1.0",
"environment": "*",

View File

@ -22,6 +22,6 @@ include("libjf-data-manipulation-v0")
include("libjf-devutil")
include("libjf-translate-v1")
include("libjf-unsafe-v0")
include("libjf-web-v0")
include("libjf-web-v1")
include("libjf-config-compiler-plugin-v2")