Skip to content

Instantly share code, notes, and snippets.

@nigjo
Last active September 21, 2024 11:16
Show Gist options
  • Save nigjo/9007e06c58f16aecafb7a423ea3c14d1 to your computer and use it in GitHub Desktop.
Save nigjo/9007e06c58f16aecafb7a423ea3c14d1 to your computer and use it in GitHub Desktop.
A simple, insecure One-File-Java-Server to serve static pages. Main purpose is to have a simple server to locally test some github pages.
/*
* Copyright 2020 Jens "Nigjo" Hofschröer.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.gist.nigjo.server;
/**
* Runs a simple Debug Server in a local folder. This file can be directly started with
* Java 11+ in a console. May be used from other Java classes.
*
* <strong>DO NOT RUN ON A PUBLIC SYSTEM!</strong>
*
* <h2>Syntax</h2>
* <pre>java Server.java [&lt;port&gt;] [&lt;prefix&gt;] [[&lt;fileext&gt;=&lt;mediatype&gt;]...]</pre>
*
* <dl>
* <dt>{@code <port>}
* </dt><dd>must be a positive integer. Default port is 8080.</dd>
* <dt>{@code <prefix>}
* </dt><dd>is a path prefix the server expects for every request. The default prefix is
* "/"</dd>
* <dt>{@code <fileext>=<mediatype>}
* </dt><dd>adds more known media types to the server via cli. some basic media types are
* defined in this class: {@code html}, {@code js}, {@code css}, {@code jpg}, {@code png},
* {@code ico} and {@code json}.</dd>
* </dl>
*
* <h2>System properties</h2>
* <p>
* Some System Properties recognized by this class:</p>
*
* <dl>
* <dt>{@code com.github.gist.nigjo.server.defaultfile}
* </dt><dd>Fallback for folder requests. Defaults to "{@code index.html}".</dd>
* <dt>{@code com.github.gist.nigjo.server.allowDirlist}
* </dt><dd>Show folder content if "{@code Server.defaultfile}" is not found. Defaults to
* "{@code false}". Will always send a 404 resonse code.</dd>
* <dt>{@code com.github.gist.nigjo.server.multithread}
* </dt><dd>Use multiple threads to serve the requests. Defaults to "{@code false}".</dd>
* </dl>
*
* <h2>Source</h2>
* The original sources are from
* https://gist.github.com/nigjo/9007e06c58f16aecafb7a423ea3c14d1
*
* last changed: 2024-09-15
*/
public class Server
{
final java.util.Map<String, String> types =
new java.util.HashMap<>(java.util.Map.of(
"html", "text/html",
"js", "text/javascript",
"css", "text/css",
"jpg", "image/jpeg",
"png", "image/png",
"ico", "image/x-icon",
"json", "application/json"
));
private int port = 8080;
private String prefix = "/";
private String indexFile =
System.getProperty("com.github.gist.nigjo.server.defaultfile", "index.html");
private boolean allowDirlist =
Boolean.getBoolean("com.github.gist.nigjo.server.allowDirlist");
private boolean multithread =
Boolean.getBoolean("com.github.gist.nigjo.server.multithread");
private java.io.File rootdir = new java.io.File(".");
private java.util.function.Consumer<String> logger;
private final java.util.Map<Integer, HandlerData> handlers =
new java.util.TreeMap<>();
private static final class HandlerData
{
RequestFilter filter;
ResponseWriter writer;
public HandlerData(RequestFilter filter, ResponseWriter writer)
{
this.filter = filter;
this.writer = writer;
}
}
public Server()
{
initDefaultHandlers();
}
private void initDefaultHandlers()
{
handlers.put(10000, new HandlerData((t, uri, local, dirRequest)
-> local != null && local.exists(), this::sendLocalFile));
handlers.put(Integer.MAX_VALUE / 2,
new HandlerData((t, uri, local, dirRequest) -> dirRequest && allowDirlist
&& local != null && local.getParentFile() != null,
this::sendDirectoryListing));
handlers.put(Integer.MAX_VALUE,
new HandlerData((t, u, f, d) -> true, this::sendErrorPage));
}
public Server setPort(int port)
{
this.port = port;
return this;
}
public Server setPrefix(String prefix)
{
this.prefix = prefix;
return this;
}
public Server setRootdir(java.io.File rootdir)
{
this.rootdir = rootdir;
return this;
}
public int getPort()
{
return port;
}
public String getPrefix()
{
return prefix;
}
public java.io.File getRootdir()
{
return rootdir;
}
public String getIndexFile()
{
return indexFile;
}
public Server setIndexFile(String indexFile)
{
this.indexFile = indexFile;
return this;
}
public boolean isAllowDirlist()
{
return allowDirlist;
}
public Server setAllowDirlist(boolean allowDirlist)
{
this.allowDirlist = allowDirlist;
return this;
}
public Server addType(String extension, String mediatype)
{
types.putIfAbsent(extension, mediatype);
return this;
}
public Server setLogger(java.util.function.Consumer<String> logger)
{
this.logger = logger;
return this;
}
public boolean isMultithread()
{
return multithread;
}
public void setMultithread(boolean multithread)
{
this.multithread = multithread;
}
/**
* Adds or changes a handler for requests.
*
* @param position Position for the new/existing Handler. If a handler exists at that
* position it will be overwriten. The default static file handler is at position 10000,
* the "directory list view" at MAX_INTEGER/2.
* @param filter A filter if a specific request should be handled by {@code writer}. The
* first filter that will return {@code true} will be used.
* @param writer The handler for the request.
*/
public void setHandler(int position, RequestFilter filter, ResponseWriter writer)
{
handlers.put(position, new HandlerData(
java.util.Objects.requireNonNull(filter),
java.util.Objects.requireNonNull(writer)));
}
private static void args(Server server, String[] a)
{
for(String arg : a)
{
try
{
server.setPort(Integer.parseInt(arg));
}
catch(NumberFormatException ex)
{
if(arg.indexOf('=') > 0)
{
String pair[] = arg.split("=", 2);
server.addType(pair[0], pair[1]);
}
else
{
String prefix = arg;
if(prefix.charAt(0) != '/')
{
prefix = '/' + prefix;
}
if(prefix.charAt(prefix.length() - 1) != '/')
{
prefix += '/';
}
server.setPrefix(prefix);
}
}
}
}
public static void main(String[] a) throws java.io.IOException
{
Server server = new Server();
server.setLogger(System.out::println);
args(server, a);
server.startServer();
}
/**
* Create all context handlers for the server. The default implementation will only
* serve all static files.
*
* @param server
*/
protected void createContext(com.sun.net.httpserver.HttpServer server)
{
server.createContext("/", this::handleRequest);
}
public void startServer() throws java.io.IOException
{
java.net.InetSocketAddress host =
new java.net.InetSocketAddress(port);
com.sun.net.httpserver.HttpServer server =
com.sun.net.httpserver.HttpServer.create(host, 0);
createContext(server);
if(multithread)
{
server.setExecutor(java.util.concurrent.Executors.newCachedThreadPool());
}
server.start();
if(logger != null)
{
logger.accept("Server is running at " + toString());
}
}
@Override
public String toString()
{
return "http://localhost:" + getPort() + prefix;
}
private void handleRequest(com.sun.net.httpserver.HttpExchange t)
throws java.io.IOException
{
boolean isDirRequest;
java.net.URI requestUri = t.getRequestURI();
if(requestUri.getPath().endsWith("/"))
{
isDirRequest = true;
String query = requestUri.getQuery();
String fragment = requestUri.getFragment();
requestUri = requestUri.resolve(indexFile
+ (query == null ? "" : "?" + query)
+ (fragment == null ? "" : "#" + fragment)
);
}
else
{
isDirRequest = false;
}
java.net.URI uri = requestUri;
String path = uri.getPath();
if(multithread)
{
toLogger((b) -> b
.append(getThreadId())
.append(": ")
.append(new java.util.Date().toString())
.append(" ")
.append(path)
);
}
java.io.File requested;
if(path.startsWith(prefix))
{
requested = new java.io.File(rootdir, path.substring(prefix.length()));
}
else
{
requested = null;
}
java.util.function.Consumer<StringBuilder> logmessage = b ->
{
};
try
{
if(multithread)
{
logmessage = logmessage
.andThen(b -> b.append(getThreadId())
.append(": "));
}
//logmessage.
logmessage = logmessage
.andThen(b -> b.append(new java.util.Date().toString()))
.andThen(b -> b.append(" GET ").append(uri));
ResponseWriter responseWriter =
handlers.values().stream()
.filter(h -> h.filter.acceptsRequest(t, uri, requested, isDirRequest))
.findFirst()
.map(h -> h.writer)
.orElseThrow();
var sendLog = new StringBuilder();
ResponseSender sender =
responseWriter.writeResponse(t, uri, requested, sendLog);
logmessage = logmessage.andThen(sb -> sb.append(sendLog));
try(java.io.OutputStream os = t.getResponseBody())
{
sender.sendResponse(os);
}
catch(java.io.IOException ex)
{
logmessage = logmessage
.andThen(b -> b.append(":: "))
.andThen(b -> b.append(ex.toString()));
throw ex;
}
}
finally
{
toLogger(logmessage);
}
}
private ResponseSender sendErrorPage(
com.sun.net.httpserver.HttpExchange t, java.net.URI uri,
java.io.File local, StringBuilder logmessage)
throws java.io.IOException
{
logmessage.append(" 404");
String response = "File not found " + uri.toString();
t.sendResponseHeaders(404, response.length());
return (out) -> out.write(response.getBytes());
}
private ResponseSender sendDirectoryListing(
com.sun.net.httpserver.HttpExchange t, java.net.URI uri,
java.io.File local, StringBuilder logmessage)
throws java.io.IOException
{
logmessage.append(" ").append(types.get("html"));
t.getResponseHeaders()
.add("Content-Type", types.get("html"));
StringBuilder dirlist = new StringBuilder();
dirlist.append("<p>Folder content of <code>")
.append(uri.getPath())
.append("</code></p>");
dirlist.append("<ul>");
if(!local.getParentFile().equals(rootdir))
{
dirlist.append("<li><code><a href='../'>../</a></code></li>");
}
for(java.io.File child : local.getParentFile().listFiles())
{
String childname = child.getName() + (child.isDirectory() ? "/" : "");
dirlist.append("<li><code><a href='")
.append(childname)
.append("'>")
.append(childname)
.append("</a></code></li>");
}
dirlist.append("</ul>");
String response = dirlist.toString();
t.sendResponseHeaders(404, response.length());
return (out) -> out.write(response.getBytes());
}
private ResponseSender sendLocalFile(
com.sun.net.httpserver.HttpExchange t, java.net.URI uri,
java.io.File local, StringBuilder logmessage)
throws java.io.IOException
{
//String response = "This is the response of "+local.getAbsolutePath();
String filename = local.getName();
String ext = filename.substring(filename.lastIndexOf('.') + 1);
if(types.containsKey(ext))
{
logmessage.append(" ").append(types.get(ext));
t.getResponseHeaders()
.add("Content-Type", types.get(ext));
}
logmessage.append(" 200 ").append(local.length());
t.sendResponseHeaders(200, local.length());
return (o) -> java.nio.file.Files.copy(local.toPath(), o);
}
@SuppressWarnings("deprecation")
private static long getThreadId()
{
Runtime.Version vmVersion = Runtime.Version.parse(System
.getProperty("java.vm.version"));
try
{
if(vmVersion.feature() >= 19)
{
return (Long)Thread.class.getMethod("threadId").invoke(null);
}
else
{
return (Long)Thread.class.getMethod("getId").invoke(null);
}
}
catch(ReflectiveOperationException ex)
{
System.err.println(ex.toString());
return 0;
}
}
protected void toLogger(java.util.function.Consumer<StringBuilder> logmessage)
{
if(logger != null)
{
StringBuilder fullMessage = new StringBuilder();
logmessage.accept(fullMessage);
logger.accept(fullMessage.toString());
}
}
/**
* A Consumer to write the Response-Content to an OutputStream.
*/
@FunctionalInterface
public static interface ResponseSender
{
/**
* Sends the content body of the response. This should match the size in the
* {@link com.sun.net.httpserver.HttpExchange#sendResponseHeaders(int, long)} call
* from the {@link ResponseWriter}.
*
* @param out Stream to write to.
*
* @throws java.io.IOException Any problem while sending the response content.
*/
void sendResponse(java.io.OutputStream out) throws java.io.IOException;
}
/**
* A Handler for a request. Some work is already done link mapping the request to a
* local file. "Defaultfile"-mapping for directory request are handled for that.
*
* <p>
* <strong>IMPORTANT:</strong>An implementation must call
* {@link com.sun.net.httpserver.HttpExchange#sendResponseHeaders(int, long) t.sendResponseHeaders()}
* with valid parameter values.
*/
@FunctionalInterface
public static interface ResponseWriter
{
/**
* Creates a response for a Request.
*
* @param t Server Request/Response Instance. Can be used to get request headers or
* set response headers. Do not call
* {@link com.sun.net.httpserver.HttpExchange#getResponseBody()} as this is managed
* otherwise.
* @param uri the request uri, modified to map "indexFile" requests.
* @param localFile the request uri, mapping to a local file path.
* @param logmessage buffer to be added to the server log message. Do not include any
* line breaks.
*
* @return A provider for the response content. This should match the size in the
* {@link com.sun.net.httpserver.HttpExchange#sendResponseHeaders(int, long)} call.
*
* @throws java.io.IOException any problem while creating the response.
*/
ResponseSender writeResponse(
com.sun.net.httpserver.HttpExchange t, java.net.URI uri,
java.io.File localFile, StringBuilder logmessage)
throws java.io.IOException;
}
/**
* Filter for a Request in conjunction with a ResponseWriter. Every ResponseWriter needs
* a RequestFilter that is filters a Request to be handled by the ResponseWriter.
*
* @see #addHandler
*/
@FunctionalInterface
public static interface RequestFilter
{
boolean acceptsRequest(
com.sun.net.httpserver.HttpExchange t, java.net.URI uri,
java.io.File localFile, boolean isDirRequest);
}
}
/*
* Copyright 2020-2024 Jens "Nigjo" Hofschröer.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package com.github.gist.nigjo.server;
import java.io.ByteArrayInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.net.InetSocketAddress;
import java.nio.charset.StandardCharsets;
import java.nio.file.Path;
import java.util.ArrayList;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.Objects;
/**
* A simple, insecure Server that can execute Single-File Source-Code Programs as
* CGI-Scripts.
*
* To run this Single-File Source-Code Program you must compile the {@code Server} class
* beforehand (or your JDK is capable of Multi-File Source-Code Programs):
*
* <pre>javac -d . Server.java</pre>
*
* Otherwise you get ClassNotFoundExceptions.
*
* @author nigjo
*/
public class ServerCgi
{
public static final String GATEWAY_INTERFACE = "GATEWAY_INTERFACE";
public static final String QUERY_STRING = "QUERY_STRING";
public static final String REMOTE_ADDR = "REMOTE_ADDR";
public static final String REQUEST_METHOD = "REQUEST_METHOD";
public static final String SCRIPT_NAME = "SCRIPT_NAME";
public static final String SERVER_NAME = "SERVER_NAME";
public static final String SERVER_PORT = "SERVER_PORT";
public static final String SERVER_PROTOCOL = "SERVER_PROTOCOL";
public static final String SERVER_SOFTWARE = "SERVER_SOFTWARE";
public static final String PATH_INFO = "PATH_INFO";
private static final String CGIBIN_DIR = System.getProperty(
"com.github.gist.nigjo.server.cgibin_prefix", "/cgi-bin/");
private static void debugLog()
{
//System.err.println();
}
private static void debugLog(Object msg)
{
//System.err.println(msg);
}
private static void debugText(Object msg)
{
//System.err.print(msg);
}
/**
* @param args the command line arguments
*/
public static void main(String args[]) throws Exception
{
Server server = new Server();
server.setLogger(System.out::println);
server.setAllowDirlist(true);
server.setHandler(5000, (t, uri, localFile, isDirRequest) ->
{
if(uri.getPath().startsWith(CGIBIN_DIR) && localFile != null)
{
Path requestPath = Path.of(uri.getPath());
debugLog("??requestPath=" + requestPath);
Path localPath = localFile.toPath();
debugLog("??localPath=" + localPath);
Path cgiPath = requestPath.subpath(0, 2);
debugLog("??cgiPath=" + cgiPath);
while(localPath != null && !localPath.endsWith(cgiPath))
localPath = localPath.getParent();
debugLog("??localPath=" + localPath);
if(localPath != null && localPath.toFile().exists()
&& localPath.toString().endsWith(".java"))
{
return true;
}
}
return false;
}, (t, uri, localFile, logmessage) ->
{
Map<String, String> cgienv = new HashMap<>();
if(!"GET".equals(t.getRequestMethod()))
{
t.sendResponseHeaders(405, 0);
return out -> out.write("Method not allowd".getBytes());
}
cgienv.put("RequestURI", t.getRequestURI().toASCIIString());
cgienv.put("header", t.getRequestHeaders().toString());
cgienv.put(GATEWAY_INTERFACE, "CGI/1.1");
cgienv.put(REMOTE_ADDR, t.getRemoteAddress().getHostName()
+ ':' + t.getRemoteAddress().getPort());
cgienv.put(REQUEST_METHOD, t.getRequestMethod());
cgienv.put(SCRIPT_NAME, uri.getPath());
InetSocketAddress localAddress = t.getLocalAddress();
cgienv.put(SERVER_NAME,
Objects.toString(t.getRequestHeaders().getFirst("HOST")
.replaceFirst(":\\d+$", ""),
localAddress.getHostName())
);
cgienv.put(SERVER_SOFTWARE, ServerCgi.class.getName() + "/1.0");
cgienv.put(SERVER_PROTOCOL, "HTTP/1.0");
cgienv.put(SERVER_PORT, String.valueOf(server.getPort()));
cgienv.put("JAVA_HOME", System.getProperty("java.home"));
Path localPath = localFile.toPath();
debugLog("<<localPath=" + localPath);
Path requestPath = Path.of(uri.getPath());
debugLog("<<requestPath=" + requestPath);
if(requestPath.getNameCount() > 2)
{
Path cgiPath = requestPath.subpath(0, 2);
debugLog("<<cgiPath=" + cgiPath);
Path pathInfo = requestPath.subpath(2, requestPath.getNameCount());
debugLog("<<pathInfo=" + pathInfo);
localPath = localPath.subpath(0, localPath.getNameCount() - pathInfo
.getNameCount());
debugLog("<<realPath=" + localPath);
// while(localPath != null && !localPath.endsWith(cgiPath))
// localPath = localPath.getParent();
//cgienv = new HashMap<>(cgienv);
cgienv.put(PATH_INFO, '/' + pathInfo.toString().replace('\\', '/'));
cgienv.put(SCRIPT_NAME, '/' + cgiPath.toString().replace('\\', '/'));
}
String query = uri.getQuery();
if(query != null && !query.isBlank() && !"?".equals(query.trim()))
{
cgienv.put(QUERY_STRING, query);
}
t.getRequestHeaders()
.forEach((k, v) ->
{
cgienv.put("HTTP_" + k.toUpperCase().replace('-', '_'),
String.join(" ", v));
});
List<String> command = new ArrayList<>();
command.add(Path.of(System.getProperty("java.home"))
.resolve("bin/java")
.toString());
Map.of(
"file.encoding", "UTF-8"
).forEach((p, v) -> command.add("-D" + p + "=" + v));
command.add(localPath.toString());
//t.getResponseHeaders().add("Content-Type", "text/html; charset=utf-8");
debugLog("bin:" + command);
try
{
ProcessBuilder builder = new ProcessBuilder()
.command(command)
.directory(Path.of(System.getProperty("user.dir")).toFile())
.redirectErrorStream(true);
builder.environment().clear();
builder.environment().putAll(cgienv);
Process binScript = builder.start();
int returnStatus = 200;
InputStream headerReader = binScript.getInputStream();
StringBuilder buffer;
do
{
debugText("##");
buffer = new StringBuilder();
int val = headerReader.read();
while(val != '\n')
{
if(val < 0)
{
break;
}
if(val != '\r')
{
buffer.append(Character.toString(val));
}
val = headerReader.read();
}
if(buffer.length() > 0)
{
debugText(buffer);
String[] headerEntry = buffer.toString().split(":\\s*", 2);
if(headerEntry.length == 2)
{
switch(headerEntry[0])
{
case "Location":
t.getResponseHeaders().add(headerEntry[0], headerEntry[1]);
returnStatus = returnStatus == 200 ? 302 : returnStatus;
break;
case "Stauts":
returnStatus = Integer.parseInt(headerEntry[1]);
break;
default:
t.getResponseHeaders().add(headerEntry[0], headerEntry[1]);
break;
}
}
}
debugLog();
}
while(buffer.length() != 0);
t.sendResponseHeaders(returnStatus, 0);
return out -> binScript.getInputStream().transferTo(out);
}
catch(IOException ex)
{
String msg = ex.toString();
byte[] bytes = msg.getBytes(StandardCharsets.UTF_8);
t.sendResponseHeaders(500, bytes.length);
return (o) -> new ByteArrayInputStream(bytes).transferTo(o);
}
});
server.startServer();
}
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment