totally rework LocalHTTPD based on nanohttpd 2.3.1

The webserver was totally broken since nanohttpd had changed so much since
the swap webserver was implemented.  This syncs up with the sample file and
gets rid of our hacks.  The only differences now are the stuff that is
removed since it is totally unused in F-Droid.  This also adds a full test
suite.

this actually closes #248
This commit is contained in:
Hans-Christoph Steiner 2018-08-06 16:57:33 +02:00
parent f1e5653601
commit 738216c205
7 changed files with 815 additions and 274 deletions

View File

@ -1,21 +1,52 @@
package org.fdroid.fdroid.net;
/*
* #%L
* NanoHttpd-Webserver
* %%
* Copyright (C) 2012 - 2015 nanohttpd
* %%
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the nanohttpd nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
* OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
* OF THE POSSIBILITY OF SUCH DAMAGE.
* #L%
*/
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.util.Log;
import fi.iki.elonen.NanoHTTPD;
import fi.iki.elonen.NanoHTTPD.Response.IStatus;
import org.fdroid.fdroid.BuildConfig;
import org.fdroid.fdroid.Utils;
import org.fdroid.fdroid.localrepo.LocalRepoKeyStore;
import org.fdroid.fdroid.views.swap.SwapWorkflowActivity;
import javax.net.ssl.SSLServerSocketFactory;
import java.io.File;
import java.io.FileInputStream;
import java.io.FileNotFoundException;
import java.io.FilenameFilter;
import java.io.IOException;
import java.io.InputStream;
import java.io.UnsupportedEncodingException;
import java.net.URLEncoder;
import java.util.Arrays;
@ -26,15 +57,36 @@ import java.util.List;
import java.util.Map;
import java.util.StringTokenizer;
/**
* A HTTP server for serving the files that are being swapped via WiFi, etc.
* The only changes were to remove unneeded extras like {@code main()}, the
* plugin interface, and custom CORS header manipulation.
* <p>
* This is mostly just synced from {@code SimpleWebServer.java} from NanoHTTPD.
*
* @see <a href="https://github.com/NanoHttpd/nanohttpd/blob/nanohttpd-project-2.3.1/webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java">webserver/src/main/java/fi/iki/elonen/SimpleWebServer.java</a>
*/
public class LocalHTTPD extends NanoHTTPD {
private static final String TAG = "LocalHTTPD";
private final Context context;
private final File webRoot;
/**
* Default Index file names.
*/
public static final String[] INDEX_FILE_NAMES = {"index.html"};
private final Context context;
protected List<File> rootDirs;
/**
* Configure and start the webserver. This also sets the MIME Types only
* for files that should be downloadable when a browser is used to display
* the swap repo, rather than the F-Droid client. The other file types
* should not be added because it could expose exploits to the browser.
*/
public LocalHTTPD(Context context, String hostname, int port, File webRoot, boolean useHttps) {
super(hostname, port);
this.webRoot = webRoot;
rootDirs = Collections.singletonList(webRoot);
this.context = context.getApplicationContext();
if (useHttps) {
enableHTTPS();
@ -46,278 +98,63 @@ public class LocalHTTPD extends NanoHTTPD {
MIME_TYPES.put("xml", "application/xml");
}
private boolean canServeUri(String uri, File homeDir) {
boolean canServeUri;
File f = new File(homeDir, uri);
canServeUri = f.exists();
return canServeUri;
}
/**
* URL-encodes everything between "/"-characters. Encodes spaces as '%20'
* instead of '+'.
*/
private String encodeUriBetweenSlashes(String uri) {
private String encodeUri(String uri) {
String newUri = "";
StringTokenizer st = new StringTokenizer(uri, "/ ", true);
while (st.hasMoreTokens()) {
String tok = st.nextToken();
switch (tok) {
case "/":
newUri += "/";
break;
case " ":
newUri += "%20";
break;
default:
try {
newUri += URLEncoder.encode(tok, "UTF-8");
} catch (UnsupportedEncodingException ignored) {
}
break;
if ("/".equals(tok)) {
newUri += "/";
} else if (" ".equals(tok)) {
newUri += "%20";
} else {
try {
newUri += URLEncoder.encode(tok, "UTF-8");
} catch (UnsupportedEncodingException ignored) {
}
}
}
return newUri;
}
}
@Override
public Response serve(IHTTPSession session) {
if (session.getMethod() == Method.POST) {
try {
session.parseBody(new HashMap<String, String>());
} catch (IOException e) {
Log.e(TAG, "An error occured while parsing the POST body", e);
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT,
"Internal server error, check logcat on server for details.");
} catch (ResponseException re) {
return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage());
}
return handlePost(session);
}
return handleGet(session);
}
private Response handlePost(IHTTPSession session) {
Uri uri = Uri.parse(session.getUri());
switch (uri.getPath()) {
case "/request-swap":
if (!session.getParms().containsKey("repo")) {
Log.e(TAG, "Malformed /request-swap request to local repo HTTP server."
+ " Should have posted a 'repo' parameter.");
return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT,
"Requires 'repo' parameter to be posted.");
}
SwapWorkflowActivity.requestSwap(context, session.getParms().get("repo"));
return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received.");
}
return newFixedLengthResponse("");
}
private Response handleGet(IHTTPSession session) {
Map<String, String> header = session.getHeaders();
Map<String, String> parms = session.getParms();
String uri = session.getUri();
if (BuildConfig.DEBUG) {
Utils.debugLog(TAG, session.getMethod() + " '" + uri + "' ");
Iterator<String> e = header.keySet().iterator();
while (e.hasNext()) {
String value = e.next();
Utils.debugLog(TAG, " HDR: '" + value + "' = '" + header.get(value) + "'");
}
e = parms.keySet().iterator();
while (e.hasNext()) {
String value = e.next();
Utils.debugLog(TAG, " PRM: '" + value + "' = '" + parms.get(value) + "'");
}
}
if (!webRoot.isDirectory()) {
return createResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT,
"INTERNAL ERRROR: given path is not a directory (" + webRoot + ").");
}
return respond(Collections.unmodifiableMap(header), uri);
}
private void enableHTTPS() {
try {
LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context);
SSLServerSocketFactory factory = NanoHTTPD.makeSSLSocketFactory(
localRepoKeyStore.getKeyStore(),
localRepoKeyStore.getKeyManagers());
makeSecure(factory, null);
} catch (LocalRepoKeyStore.InitException | IOException e) {
Log.e(TAG, "Could not enable HTTPS", e);
}
}
private Response respond(Map<String, String> headers, String uri) {
// Remove URL arguments
uri = uri.trim().replace(File.separatorChar, '/');
if (uri.indexOf('?') >= 0) {
uri = uri.substring(0, uri.indexOf('?'));
}
// Prohibit getting out of current directory
if (uri.contains("../")) {
return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
"FORBIDDEN: Won't serve ../ for security reasons.");
}
File f = new File(webRoot, uri);
if (!f.exists()) {
return createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
"Error 404, file not found.");
}
// Browsers get confused without '/' after the directory, send a
// redirect.
if (f.isDirectory() && !uri.endsWith("/")) {
uri += "/";
Response res = createResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML,
"<html><body>Redirected: <a href=\"" +
uri + "\">" + uri + "</a></body></html>");
res.addHeader("Location", uri);
return res;
}
if (f.isDirectory()) {
// First look for index files (index.html, index.htm, etc) and if
// none found, list the directory if readable.
String indexFile = findIndexFileInDirectory(f);
if (indexFile == null) {
if (f.canRead()) {
// No index file, list the directory if it is readable
return createResponse(Response.Status.OK, NanoHTTPD.MIME_HTML,
listDirectory(uri, f));
} else {
return createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
"FORBIDDEN: No directory listing.");
}
} else {
return respond(headers, uri + indexFile);
}
}
Response response = serveFile(headers, f, getAndroidMimeTypeForFile(uri));
return response != null ? response :
createResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT,
"Error 404, file not found.");
}
/**
* Serves file from homeDir and its' subdirectories (only). Uses only URI,
* ignores all headers and HTTP parameters.
*/
private Response serveFile(Map<String, String> header, File file, String mime) {
Response res;
try {
// Calculate etag
String etag = Integer
.toHexString((file.getAbsolutePath() + file.lastModified() + String.valueOf(file.length()))
.hashCode());
// Support (simple) skipping:
long startFrom = 0;
long endAt = -1;
String range = header.get("range");
if (range != null && range.startsWith("bytes=")) {
range = range.substring("bytes=".length());
int minus = range.indexOf('-');
try {
if (minus > 0) {
startFrom = Long.parseLong(range.substring(0, minus));
endAt = Long.parseLong(range.substring(minus + 1));
}
} catch (NumberFormatException ignored) {
}
}
// Change return code and add Content-Range header when skipping is
// requested
long fileLen = file.length();
if (range != null && startFrom >= 0) {
if (startFrom >= fileLen) {
res = createResponse(Response.Status.RANGE_NOT_SATISFIABLE,
NanoHTTPD.MIME_PLAINTEXT, "");
res.addHeader("Content-Range", "bytes 0-0/" + fileLen);
res.addHeader("ETag", etag);
} else {
if (endAt < 0) {
endAt = fileLen - 1;
}
long newLen = endAt - startFrom + 1;
if (newLen < 0) {
newLen = 0;
}
final long dataLen = newLen;
FileInputStream fis = new FileInputStream(file) {
@Override
public int available() throws IOException {
return (int) dataLen;
}
};
long skipped = fis.skip(startFrom);
if (skipped != startFrom) {
throw new IOException("unable to skip the required " + startFrom + " bytes.");
}
res = createResponse(Response.Status.PARTIAL_CONTENT, mime, fis);
res.addHeader("Content-Length", String.valueOf(dataLen));
res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/"
+ fileLen);
res.addHeader("ETag", etag);
}
} else {
if (etag.equals(header.get("if-none-match"))) {
res = createResponse(Response.Status.NOT_MODIFIED, mime, "");
} else {
res = createResponse(Response.Status.OK, mime, new FileInputStream(file));
res.addHeader("Content-Length", String.valueOf(fileLen));
res.addHeader("ETag", etag);
}
}
} catch (IOException ioe) {
res = createResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT,
"FORBIDDEN: Reading file failed.");
}
return res;
}
// Announce that the file server accepts partial content requests
private Response createResponse(Response.Status status, String mimeType, InputStream message) {
Response res = newChunkedResponse(status, mimeType, message);
res.addHeader("Accept-Ranges", "bytes");
return res;
}
// Announce that the file server accepts partial content requests
private Response createResponse(Response.Status status, String mimeType, String message) {
Response res = newFixedLengthResponse(status, mimeType, message);
res.addHeader("Accept-Ranges", "bytes");
return res;
}
private String findIndexFileInDirectory(File directory) {
String indexFileName = "index.html";
File indexFile = new File(directory, indexFileName);
if (indexFile.exists()) {
return indexFileName;
for (String fileName : LocalHTTPD.INDEX_FILE_NAMES) {
File indexFile = new File(directory, fileName);
if (indexFile.isFile()) {
return fileName;
}
}
return null;
}
private String listDirectory(String uri, File f) {
protected Response getForbiddenResponse(String s) {
return newFixedLengthResponse(Response.Status.FORBIDDEN, NanoHTTPD.MIME_PLAINTEXT, "FORBIDDEN: " + s);
}
protected Response getInternalErrorResponse(String s) {
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, NanoHTTPD.MIME_PLAINTEXT, "INTERNAL ERROR: " + s);
}
protected Response getNotFoundResponse() {
return newFixedLengthResponse(Response.Status.NOT_FOUND, NanoHTTPD.MIME_PLAINTEXT, "Error 404, file not found.");
}
protected String listDirectory(String uri, File f) {
String heading = "Directory " + uri;
StringBuilder msg = new StringBuilder("<html><head><title>" + heading
+ "</title><style><!--\n" +
"span.dirname { font-weight: bold; }\n" +
"span.filesize { font-size: 75%; }\n" +
"// -->\n" +
"</style>" +
"</head><body><h1>" + heading + "</h1>");
StringBuilder msg =
new StringBuilder("<html><head><title>" + heading + "</title><style><!--\n" + "span.dirname { font-weight: bold; }\n" + "span.filesize { font-size: 75%; }\n"
+ "// -->\n" + "</style>" + "</head><body><h1>" + heading + "</h1>");
String up = null;
if (uri.length() > 1) {
@ -329,6 +166,7 @@ public class LocalHTTPD extends NanoHTTPD {
}
List<String> files = Arrays.asList(f.list(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return new File(dir, name).isFile();
@ -336,6 +174,7 @@ public class LocalHTTPD extends NanoHTTPD {
}));
Collections.sort(files);
List<String> directories = Arrays.asList(f.list(new FilenameFilter() {
@Override
public boolean accept(File dir, String name) {
return new File(dir, name).isDirectory();
@ -347,35 +186,27 @@ public class LocalHTTPD extends NanoHTTPD {
if (up != null || directories.size() > 0) {
msg.append("<section class=\"directories\">");
if (up != null) {
msg.append("<li><a rel=\"directory\" href=\"").append(up)
.append("\"><span class=\"dirname\">..</span></a></b></li>");
msg.append("<li><a rel=\"directory\" href=\"").append(up).append("\"><span class=\"dirname\">..</span></a></li>");
}
for (String directory : directories) {
String dir = directory + "/";
msg.append("<li><a rel=\"directory\" href=\"")
.append(encodeUriBetweenSlashes(uri + dir))
.append("\"><span class=\"dirname\">").append(dir)
.append("</span></a></b></li>");
msg.append("<li><a rel=\"directory\" href=\"").append(encodeUri(uri + dir)).append("\"><span class=\"dirname\">").append(dir).append("</span></a></li>");
}
msg.append("</section>");
}
if (files.size() > 0) {
msg.append("<section class=\"files\">");
for (String file : files) {
msg.append("<li><a href=\"").append(encodeUriBetweenSlashes(uri + file))
.append("\"><span class=\"filename\">").append(file)
.append("</span></a>");
msg.append("<li><a href=\"").append(encodeUri(uri + file)).append("\"><span class=\"filename\">").append(file).append("</span></a>");
File curFile = new File(f, file);
long len = curFile.length();
msg.append("&nbsp;<span class=\"filesize\">(");
if (len < 1024) {
msg.append(len).append(" bytes");
} else if (len < 1024 * 1024) {
msg.append(len / 1024).append('.').append(len % 1024 / 10 % 100)
.append(" KB");
msg.append(len / 1024).append(".").append(len % 1024 / 10 % 100).append(" KB");
} else {
msg.append(len / (1024 * 1024)).append('.')
.append(len % (1024 * 1024) / 10 % 100).append(" MB");
msg.append(len / (1024 * 1024)).append(".").append(len % (1024 * 1024) / 10000 % 100).append(" MB");
}
msg.append(")</span></li>");
}
@ -386,4 +217,252 @@ public class LocalHTTPD extends NanoHTTPD {
msg.append("</body></html>");
return msg.toString();
}
public static Response newFixedLengthResponse(IStatus status, String mimeType, String message) {
Response response = NanoHTTPD.newFixedLengthResponse(status, mimeType, message);
response.addHeader("Accept-Ranges", "bytes");
return response;
}
private Response respond(Map<String, String> headers, IHTTPSession session, String uri) {
return defaultRespond(headers, session, uri);
}
private Response defaultRespond(Map<String, String> headers, IHTTPSession session, String uri) {
// Remove URL arguments
uri = uri.trim().replace(File.separatorChar, '/');
if (uri.indexOf('?') >= 0) {
uri = uri.substring(0, uri.indexOf('?'));
}
// Prohibit getting out of current directory
if (uri.contains("../")) {
return getForbiddenResponse("Won't serve ../ for security reasons.");
}
boolean canServeUri = false;
File homeDir = null;
for (int i = 0; !canServeUri && i < this.rootDirs.size(); i++) {
homeDir = this.rootDirs.get(i);
canServeUri = canServeUri(uri, homeDir);
}
if (!canServeUri) {
return getNotFoundResponse();
}
// Browsers get confused without '/' after the directory, send a
// redirect.
File f = new File(homeDir, uri);
if (f.isDirectory() && !uri.endsWith("/")) {
uri += "/";
Response res =
newFixedLengthResponse(Response.Status.REDIRECT, NanoHTTPD.MIME_HTML, "<html><body>Redirected: <a href=\"" + uri + "\">" + uri + "</a></body></html>");
res.addHeader("Location", uri);
return res;
}
if (f.isDirectory()) {
// First look for index files (index.html, index.htm, etc) and if
// none found, list the directory if readable.
String indexFile = findIndexFileInDirectory(f);
if (indexFile == null) {
if (f.canRead()) {
// No index file, list the directory if it is readable
return newFixedLengthResponse(Response.Status.OK, NanoHTTPD.MIME_HTML, listDirectory(uri, f));
} else {
return getForbiddenResponse("No directory listing.");
}
} else {
return respond(headers, session, uri + indexFile);
}
}
String mimeTypeForFile = getMimeTypeForFile(uri);
Response response = serveFile(uri, headers, f, mimeTypeForFile);
return response != null ? response : getNotFoundResponse();
}
@Override
public Response serve(IHTTPSession session) {
Map<String, String> header = session.getHeaders();
Map<String, String> parms = session.getParms();
String uri = session.getUri();
if (BuildConfig.DEBUG) {
System.out.println(session.getMethod() + " '" + uri + "' ");
Iterator<String> e = header.keySet().iterator();
while (e.hasNext()) {
String value = e.next();
System.out.println(" HDR: '" + value + "' = '" + header.get(value) + "'");
}
e = parms.keySet().iterator();
while (e.hasNext()) {
String value = e.next();
System.out.println(" PRM: '" + value + "' = '" + parms.get(value) + "'");
}
}
if (session.getMethod() == Method.POST) {
try {
session.parseBody(new HashMap<String, String>());
} catch (IOException e) {
return newFixedLengthResponse(Response.Status.INTERNAL_ERROR, MIME_PLAINTEXT,
"Internal server error, check logcat on server for details.");
} catch (ResponseException re) {
return newFixedLengthResponse(re.getStatus(), MIME_PLAINTEXT, re.getMessage());
}
return handlePost(session);
}
for (File homeDir : this.rootDirs) {
// Make sure we won't die of an exception later
if (!homeDir.isDirectory()) {
return getInternalErrorResponse("given path is not a directory (" + homeDir + ").");
}
}
return respond(Collections.unmodifiableMap(header), session, uri);
}
private Response handlePost(IHTTPSession session) {
Uri uri = Uri.parse(session.getUri());
switch (uri.getPath()) {
case "/request-swap":
if (!session.getParms().containsKey("repo")) {
return newFixedLengthResponse(Response.Status.BAD_REQUEST, MIME_PLAINTEXT,
"Requires 'repo' parameter to be posted.");
}
SwapWorkflowActivity.requestSwap(context, session.getParms().get("repo"));
return newFixedLengthResponse(Response.Status.OK, MIME_PLAINTEXT, "Swap request received.");
}
return newFixedLengthResponse("");
}
/**
* Serves file from homeDir and its' subdirectories (only). Uses only URI,
* ignores all headers and HTTP parameters.
*/
Response serveFile(String uri, Map<String, String> header, File file, String mime) {
Response res;
try {
// Calculate etag
String etag = Integer.toHexString((file.getAbsolutePath() + file.lastModified() + "" + file.length()).hashCode());
// Support (simple) skipping:
long startFrom = 0;
long endAt = -1;
String range = header.get("range");
if (range != null) {
if (range.startsWith("bytes=")) {
range = range.substring("bytes=".length());
int minus = range.indexOf('-');
try {
if (minus > 0) {
startFrom = Long.parseLong(range.substring(0, minus));
endAt = Long.parseLong(range.substring(minus + 1));
}
} catch (NumberFormatException ignored) {
}
}
}
// get if-range header. If present, it must match etag or else we
// should ignore the range request
String ifRange = header.get("if-range");
boolean headerIfRangeMissingOrMatching = (ifRange == null || etag.equals(ifRange));
String ifNoneMatch = header.get("if-none-match");
boolean headerIfNoneMatchPresentAndMatching = ifNoneMatch != null && ("*".equals(ifNoneMatch) || ifNoneMatch.equals(etag));
// Change return code and add Content-Range header when skipping is
// requested
long fileLen = file.length();
if (headerIfRangeMissingOrMatching && range != null && startFrom >= 0 && startFrom < fileLen) {
// range request that matches current etag
// and the startFrom of the range is satisfiable
if (headerIfNoneMatchPresentAndMatching) {
// range request that matches current etag
// and the startFrom of the range is satisfiable
// would return range from file
// respond with not-modified
res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, "");
res.addHeader("ETag", etag);
} else {
if (endAt < 0) {
endAt = fileLen - 1;
}
long newLen = endAt - startFrom + 1;
if (newLen < 0) {
newLen = 0;
}
FileInputStream fis = new FileInputStream(file);
fis.skip(startFrom);
res = newFixedLengthResponse(Response.Status.PARTIAL_CONTENT, mime, fis, newLen);
res.addHeader("Accept-Ranges", "bytes");
res.addHeader("Content-Length", "" + newLen);
res.addHeader("Content-Range", "bytes " + startFrom + "-" + endAt + "/" + fileLen);
res.addHeader("ETag", etag);
}
} else {
if (headerIfRangeMissingOrMatching && range != null && startFrom >= fileLen) {
// return the size of the file
// 4xx responses are not trumped by if-none-match
res = newFixedLengthResponse(Response.Status.RANGE_NOT_SATISFIABLE, NanoHTTPD.MIME_PLAINTEXT, "");
res.addHeader("Content-Range", "bytes */" + fileLen);
res.addHeader("ETag", etag);
} else if (range == null && headerIfNoneMatchPresentAndMatching) {
// full-file-fetch request
// would return entire file
// respond with not-modified
res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, "");
res.addHeader("ETag", etag);
} else if (!headerIfRangeMissingOrMatching && headerIfNoneMatchPresentAndMatching) {
// range request that doesn't match current etag
// would return entire (different) file
// respond with not-modified
res = newFixedLengthResponse(Response.Status.NOT_MODIFIED, mime, "");
res.addHeader("ETag", etag);
} else {
// supply the file
res = newFixedFileResponse(file, mime);
res.addHeader("Content-Length", "" + fileLen);
res.addHeader("ETag", etag);
}
}
} catch (IOException ioe) {
res = getForbiddenResponse("Reading file failed.");
}
return res;
}
private Response newFixedFileResponse(File file, String mime) throws FileNotFoundException {
Response res;
res = newFixedLengthResponse(Response.Status.OK, mime, new FileInputStream(file), (int) file.length());
res.addHeader("Accept-Ranges", "bytes");
return res;
}
private void enableHTTPS() {
try {
LocalRepoKeyStore localRepoKeyStore = LocalRepoKeyStore.get(context);
SSLServerSocketFactory factory = NanoHTTPD.makeSSLSocketFactory(
localRepoKeyStore.getKeyStore(),
localRepoKeyStore.getKeyManagers());
makeSecure(factory, null);
} catch (LocalRepoKeyStore.InitException | IOException e) {
e.printStackTrace();
}
}
protected Response addCORSHeaders(Map<String, String> queryHeaders, Response resp, String cors) {
resp.addHeader("Access-Control-Allow-Credentials", "false");
resp.addHeader("Access-Control-Allow-Methods", "GET, POST, HEAD");
// TODO add HTTP Content Security Policy headers
return resp;
}
}

View File

@ -53,7 +53,7 @@ import java.net.URL;
public class HttpDownloader extends Downloader {
private static final String TAG = "HttpDownloader";
private static final String HEADER_FIELD_ETAG = "ETag";
static final String HEADER_FIELD_ETAG = "ETag";
private final String username;
private final String password;

View File

@ -0,0 +1,449 @@
package org.fdroid.fdroid.net;
/*
* #%L
* NanoHttpd-Webserver
* %%
* Copyright (C) 2012 - 2015 nanohttpd
* %%
* Redistribution and use in source and binary forms, with or without modification,
* are permitted provided that the following conditions are met:
*
* 1. Redistributions of source code must retain the above copyright notice, this
* list of conditions and the following disclaimer.
*
* 2. Redistributions in binary form must reproduce the above copyright notice,
* this list of conditions and the following disclaimer in the documentation
* and/or other materials provided with the distribution.
*
* 3. Neither the name of the nanohttpd nor the names of its contributors
* may be used to endorse or promote products derived from this software without
* specific prior written permission.
*
* THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND
* ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE DISCLAIMED.
* IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT,
* INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING,
* BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE,
* DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF
* LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE
* OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED
* OF THE POSSIBILITY OF SUCH DAMAGE.
* #L%
*/
import android.content.Context;
import android.text.TextUtils;
import org.apache.commons.io.FileUtils;
import org.apache.commons.io.IOUtils;
import org.junit.After;
import org.junit.Assert;
import org.junit.Before;
import org.junit.Test;
import org.junit.runner.RunWith;
import org.robolectric.RobolectricTestRunner;
import org.robolectric.RuntimeEnvironment;
import org.robolectric.shadows.ShadowLog;
import java.io.File;
import java.io.FileOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.OutputStreamWriter;
import java.io.UnsupportedEncodingException;
import java.net.HttpURLConnection;
import java.net.URL;
import static org.hamcrest.CoreMatchers.containsString;
import static org.hamcrest.CoreMatchers.not;
import static org.junit.Assert.assertEquals;
import static org.junit.Assert.assertFalse;
import static org.junit.Assert.assertNotEquals;
import static org.junit.Assert.assertTrue;
/**
* Synced from NanoHTTPD's {@code TestHttpServer.java}
*
* @see <a href="https://github.com/NanoHttpd/nanohttpd/blob/nanohttpd-project-2.3.1/webserver/src/test/java/fi/iki/elonen/TestHttpServer.java">webserver/src/test/java/fi/iki/elonen/LocalHTTPDTest.java</a>
*/
@SuppressWarnings("LineLength")
@RunWith(RobolectricTestRunner.class)
public class LocalHTTPDTest {
private static ClassLoader classLoader;
private static LocalHTTPD localHttpd;
private static Thread serverStartThread;
private static File webRoot;
@Before
public void setUp() throws Exception {
ShadowLog.stream = System.out;
classLoader = getClass().getClassLoader();
final Context context = RuntimeEnvironment.application.getApplicationContext();
webRoot = context.getFilesDir();
FileUtils.deleteDirectory(webRoot);
assertTrue(webRoot.mkdir());
assertTrue(webRoot.isDirectory());
final File testdir = new File(webRoot, "testdir");
assertTrue(testdir.mkdir());
IOUtils.copy(classLoader.getResourceAsStream("test.html"),
new FileOutputStream(new File(testdir, "test.html")));
serverStartThread = new Thread(new Runnable() {
@Override
public void run() {
localHttpd = new LocalHTTPD(
context,
"localhost",
8888,
webRoot,
false);
try {
localHttpd.start();
} catch (IOException e) {
e.printStackTrace();
}
assertTrue(localHttpd.isAlive());
}
});
serverStartThread.start();
// give the server some tine to start.
Thread.sleep(100);
}
@After
public void tearDown() throws Exception {
localHttpd.stop();
serverStartThread.join(5000);
assertFalse(localHttpd.isAlive());
assertFalse(serverStartThread.isAlive());
}
@Test
public void doTest404() throws Exception {
URL url = new URL("http://localhost:8888/xxx/yyy.html");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setReadTimeout(5000);
connection.connect();
Assert.assertEquals(404, connection.getResponseCode());
connection.disconnect();
}
@Test
public void doSomeBasicTest() throws Exception {
URL url = new URL("http://localhost:8888/testdir/test.html");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
assertEquals(200, connection.getResponseCode());
String string = IOUtils.toString(connection.getInputStream(), "UTF-8");
Assert.assertEquals("<html>\n<head>\n<title>dummy</title>\n</head>\n<body>\n\t<h1>it works</h1>\n</body>\n</html>", string);
connection.disconnect();
url = new URL("http://localhost:8888/");
connection = (HttpURLConnection) url.openConnection();
assertEquals(200, connection.getResponseCode());
string = IOUtils.toString(connection.getInputStream(), "UTF-8");
System.out.println("REPLY: " + string);
assertTrue(string.indexOf("testdir") > 0);
connection.disconnect();
url = new URL("http://localhost:8888/testdir");
connection = (HttpURLConnection) url.openConnection();
assertEquals(200, connection.getResponseCode());
string = IOUtils.toString(connection.getInputStream(), "UTF-8");
assertTrue(string.indexOf("test.html") > 0);
connection.disconnect();
IOUtils.copy(classLoader.getResourceAsStream("index.microg.jar"),
new FileOutputStream(new File(webRoot, "index.microg.jar")));
url = new URL("http://localhost:8888/index.microg.jar");
connection = (HttpURLConnection) url.openConnection();
assertEquals(200, connection.getResponseCode());
byte[] actual = IOUtils.toByteArray(connection.getInputStream());
byte[] expected = IOUtils.toByteArray(classLoader.getResourceAsStream("index.microg.jar"));
Assert.assertArrayEquals(expected, actual);
connection.disconnect();
IOUtils.copy(classLoader.getResourceAsStream("extendedPerms.xml"),
new FileOutputStream(new File(webRoot, "extendedPerms.xml")));
url = new URL("http://localhost:8888/extendedPerms.xml");
connection = (HttpURLConnection) url.openConnection();
assertEquals(200, connection.getResponseCode());
actual = IOUtils.toByteArray(connection.getInputStream());
expected = IOUtils.toByteArray(classLoader.getResourceAsStream("extendedPerms.xml"));
Assert.assertArrayEquals(expected, actual);
connection.disconnect();
}
@Test
public void testAPKMimeType() throws IOException {
String fileName = "urzip.apk";
String mimeType = "application/vnd.android.package-archive";
IOUtils.copy(classLoader.getResourceAsStream(fileName),
new FileOutputStream(new File(webRoot, fileName)));
URL url = new URL("http://localhost:8888/" + fileName);
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
assertEquals(200, connection.getResponseCode());
Assert.assertEquals(mimeType, connection.getContentType());
connection.disconnect();
}
@Test
public void testHeadRequest() throws IOException {
File indexFile = new File(webRoot, "index.html");
IOUtils.copy(classLoader.getResourceAsStream("index.html"),
new FileOutputStream(indexFile));
URL url = new URL("http://localhost:8888/");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("HEAD");
String mimeType = "text/html";
System.out.println(mimeType + " " + connection.getContentType());
assertEquals(mimeType, connection.getContentType());
assertEquals(indexFile.length(), connection.getContentLength());
assertNotEquals(0, connection.getContentLength());
String etag = connection.getHeaderField(HttpDownloader.HEADER_FIELD_ETAG);
assertFalse(TextUtils.isEmpty(etag));
assertEquals(200, connection.getResponseCode());
connection.disconnect();
}
@Test
public void testPostRequest() throws IOException {
URL url = new URL("http://localhost:8888/request-swap");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setDoInput(true);
connection.setDoOutput(true);
OutputStream outputStream = connection.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(outputStream);
writer.write("repo=http://localhost:8888");
writer.flush();
writer.close();
outputStream.close();
assertEquals(200, connection.getResponseCode());
connection.disconnect();
}
@Test
public void testBadPostRequest() throws IOException {
URL url = new URL("http://localhost:8888/request-swap");
HttpURLConnection connection = (HttpURLConnection) url.openConnection();
connection.setRequestMethod("POST");
connection.setDoInput(true);
connection.setDoOutput(true);
OutputStream outputStream = connection.getOutputStream();
OutputStreamWriter writer = new OutputStreamWriter(outputStream);
writer.write("repolkasdfkjhttp://localhost:8888");
writer.flush();
writer.close();
outputStream.close();
assertEquals(400, connection.getResponseCode());
connection.disconnect();
}
@Test
public void doArgumentTest() throws InterruptedException, UnsupportedEncodingException, IOException {
final int testPort = 9458;
Thread testServer = new Thread(new Runnable() {
@Override
public void run() {
LocalHTTPD localHttpd = new LocalHTTPD(
RuntimeEnvironment.application,
"localhost",
testPort,
webRoot,
false);
try {
localHttpd.start();
} catch (IOException e) {
e.printStackTrace();
}
assertTrue(localHttpd.isAlive());
}
});
testServer.start();
Thread.sleep(200);
URL url = new URL("http://localhost:" + testPort + "/");
HttpURLConnection connection = null;
try {
connection = (HttpURLConnection) url.openConnection();
assertEquals(200, connection.getResponseCode());
String str = IOUtils.toString(connection.getInputStream(), "UTF-8");
assertTrue("The response entity didn't contain the string 'testdir'", str.contains("testdir"));
} finally {
if (connection != null) {
connection.disconnect();
}
testServer.join(5000);
}
}
@Test
public void testURLContainsParentDirectory() throws IOException {
HttpURLConnection connection = null;
URL url = new URL("http://localhost:8888/testdir/../index.html");
try {
connection = (HttpURLConnection) url.openConnection();
Assert.assertEquals("The response status should be 403(Forbidden), " + "since the server won't serve requests with '../' due to security reasons",
403, connection.getResponseCode());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
@Test
public void testIndexFileIsShownWhenURLEndsWithDirectory() throws IOException {
HttpURLConnection connection = null;
try {
String dirName = "indexDir";
File indexDir = new File(webRoot, dirName);
assertTrue(indexDir.mkdir());
IOUtils.copy(classLoader.getResourceAsStream("index.html"),
new FileOutputStream(new File(indexDir, "index.html")));
URL url = new URL("http://localhost:8888/" + dirName);
connection = (HttpURLConnection) url.openConnection();
String responseString = IOUtils.toString(connection.getInputStream(), "UTF-8");
Assert.assertThat("When the URL ends with a directory, and if an index.html file is present in that directory," + " the server should respond with that file",
responseString, containsString("Simple index file"));
IOUtils.copy(classLoader.getResourceAsStream("index.html"),
new FileOutputStream(new File(webRoot, "index.html")));
url = new URL("http://localhost:8888/");
connection = (HttpURLConnection) url.openConnection();
responseString = IOUtils.toString(connection.getInputStream(), "UTF-8");
Assert.assertThat("When the URL ends with a directory, and if an index.html file is present in that directory,"
+ " the server should respond with that file",
responseString, containsString("Simple index file"));
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
@Test
public void testRangeHeaderWithStartPositionOnly() throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL("http://localhost:8888/testdir/test.html");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestProperty("Connection", "close");
connection.addRequestProperty("range", "bytes=10-");
connection.setReadTimeout(5000);
String responseString = IOUtils.toString(connection.getInputStream(), "UTF-8");
System.out.println("responseString " + responseString);
Assert.assertThat("The data from the beginning of the file should have been skipped as specified in the 'range' header", responseString,
not(containsString("<head>")));
Assert.assertThat("The response should contain the data from the end of the file since end position was not given in the 'range' header", responseString,
containsString("</head>"));
Assert.assertEquals("The content length should be the length starting from the requested byte", "74", connection.getHeaderField("Content-Length"));
Assert.assertEquals("The 'Content-Range' header should contain the correct lengths and offsets based on the range served", "bytes 10-83/84",
connection.getHeaderField("Content-Range"));
Assert.assertEquals("Response status for a successful range request should be PARTIAL_CONTENT(206)",
206, connection.getResponseCode());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
@Test
public void testRangeStartGreaterThanFileLength() throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL("http://localhost:8888/testdir/test.html");
connection = (HttpURLConnection) url.openConnection();
connection.addRequestProperty("range", "bytes=1000-");
connection.connect();
Assert.assertEquals("Response status for a request with 'range' header value which exceeds file length should be RANGE_NOT_SATISFIABLE(416)",
416, connection.getResponseCode());
Assert.assertEquals("The 'Content-Range' header should contain the correct lengths and offsets based on the range served",
"bytes */84", connection.getHeaderField("Content-Range"));
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
@Test
public void testRangeHeaderWithStartAndEndPosition() throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL("http://localhost:8888/testdir/test.html");
connection = (HttpURLConnection) url.openConnection();
connection.addRequestProperty("range", "bytes=10-40");
String responseString = IOUtils.toString(connection.getInputStream(), "UTF-8");
Assert.assertThat("The data from the beginning of the file should have been skipped as specified in the 'range' header",
responseString, not(containsString("<head>")));
Assert.assertThat("The data from the end of the file should have been skipped as specified in the 'range' header",
responseString, not(containsString("</head>")));
Assert.assertEquals("The 'Content-Length' should be the length from the requested start position to end position",
"31", connection.getHeaderField("Content-Length"));
Assert.assertEquals("The 'Contnet-Range' header should contain the correct lengths and offsets based on the range served",
"bytes 10-40/84", connection.getHeaderField("Content-Range"));
Assert.assertEquals("Response status for a successful request with 'range' header should be PARTIAL_CONTENT(206)",
206, connection.getResponseCode());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
@Test
public void testIfNoneMatchHeader() throws IOException {
HttpURLConnection connection = null;
int status = -1;
while (status == -1) {
System.out.println("testIfNoneMatchHeader connect attempt");
try {
URL url = new URL("http://localhost:8888/testdir/test.html");
connection = (HttpURLConnection) url.openConnection();
connection.setRequestProperty("if-none-match", "*");
connection.setRequestProperty("Connection", "close");
connection.connect();
status = connection.getResponseCode();
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
Assert.assertEquals("The response status to a reqeuest with 'if-non-match=*' header should be NOT_MODIFIED(304), if the file exists",
304, status);
}
@Test
public void testRangeHeaderAndIfNoneMatchHeader() throws IOException {
HttpURLConnection connection = null;
try {
URL url = new URL("http://localhost:8888/testdir/test.html");
connection = (HttpURLConnection) url.openConnection();
connection.addRequestProperty("range", "bytes=10-20");
connection.addRequestProperty("if-none-match", "*");
Assert.assertEquals("The response status to a reqeuest with 'if-non-match=*' header and 'range' header should be NOT_MODIFIED(304),"
+ " if the file exists, because 'if-non-match' header should be given priority", 304, connection.getResponseCode());
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@ -0,0 +1,5 @@
<html>
<head>
<title>Simple index file</title>
</head>
</html>

View File

@ -0,0 +1,8 @@
<html>
<head>
<title>dummy</title>
</head>
<body>
<h1>it works</h1>
</body>
</html>

Binary file not shown.