diff --git a/README.md b/README.md index ce24136..a2916b5 100644 --- a/README.md +++ b/README.md @@ -79,12 +79,18 @@ Example code: (read the comments) * if a relative path is given, it will be relative to cordova assets/www/ in APK. * "", by default, it will point to cordova assets/www/, it's good to use 'htdocs' for 'www/htdocs' * if a absolute path is given, it will access file system. - * "/", set the root dir as the www root, it maybe a security issue, but very powerful to browse all dir + * "/", set the root dir as the www root, it maybe a security issue, but very powerful to browse all dir. + * Note the use of custom_paths (which is entirely optionaly) allows you to specify different base + * URLs, and where to serve the content from for that URL. This allows you for example to serve + * content from both the read only app storage area, as well as the read-write data directory. */ httpd.startServer({ 'www_root' : wwwroot, 'port' : 8080, - 'localhost_only' : false + 'localhost_only' : false, + 'custom_paths' : { + '/rw/' : cordova.file.dataDirectory.substring(7) + } }, function( url ){ // if server is up, it will return the url of http://:port/ // the ip is the active network connection diff --git a/plugin.xml b/plugin.xml index e95bf71..ecd48ff 100644 --- a/plugin.xml +++ b/plugin.xml @@ -50,6 +50,8 @@ xmlns:android="http://schemas.android.com/apk/res/android"> + + diff --git a/src/android/CorHttpd.java b/src/android/CorHttpd.java index 903a36b..421be4b 100644 --- a/src/android/CorHttpd.java +++ b/src/android/CorHttpd.java @@ -7,6 +7,9 @@ import java.net.NetworkInterface; import java.net.SocketException; import java.util.Enumeration; +import java.util.HashMap; +import java.util.Iterator; +import java.util.Map; import org.apache.cordova.CordovaPlugin; import org.apache.cordova.CallbackContext; @@ -42,6 +45,7 @@ public class CorHttpd extends CordovaPlugin { private static final String OPT_WWW_ROOT = "www_root"; private static final String OPT_PORT = "port"; private static final String OPT_LOCALHOST_ONLY = "localhost_only"; + private static final String OPT_CUSTOM_PATHS = "custom_paths"; private String www_root = ""; private int port = 8888; @@ -50,6 +54,8 @@ public class CorHttpd extends CordovaPlugin { private String localPath = ""; private WebServer server = null; private String url = ""; + private Map customPaths = null; + private JSONObject jsonCustomPaths = null; @Override public boolean execute(String action, JSONArray inputs, CallbackContext callbackContext) throws JSONException { @@ -107,6 +113,7 @@ private PluginResult startServer(JSONArray inputs, CallbackContext callbackConte www_root = options.optString(OPT_WWW_ROOT); port = options.optInt(OPT_PORT, 8888); localhost_only = options.optBoolean(OPT_LOCALHOST_ONLY, false); + jsonCustomPaths = options.optJSONObject(OPT_CUSTOM_PATHS); if(www_root.startsWith("/")) { //localPath = Environment.getExternalStorageDirectory().getAbsolutePath(); @@ -146,11 +153,37 @@ private String __startServer() { AssetManager am = ctx.getResources().getAssets(); f.setAssetManager( am ); + if (jsonCustomPaths != null) { + Iterator keys = jsonCustomPaths.keys(); + if (keys != null) { + customPaths = new HashMap(jsonCustomPaths.length()); + while (keys.hasNext()) { + String key = (String) keys.next(); + String path = jsonCustomPaths.optString(key); + if (!path.startsWith("/") && !path.startsWith("http://") && !path.startsWith("https://")) { + if (path.length() > 0) { + path = "www/" + path; + } else { + path = "www"; + } + } + Log.w(LOGTAG, "Custom URL - " + key + " - " + path); + if (path.startsWith("http://") || path.startsWith("https://" )) { + customPaths.put(key, path); + } else { + AndroidFile p = new AndroidFile(path); + p.setAssetManager(am); + customPaths.put(key, p); + } + } + } + } + if(localhost_only) { InetSocketAddress localAddr = InetSocketAddress.createUnresolved("127.0.0.1", port); - server = new WebServer(localAddr, f); + server = new WebServer(localAddr, f, customPaths); } else { - server = new WebServer(port, f); + server = new WebServer(port, f, customPaths); } } catch (IOException e) { errmsg = String.format("IO Exception: %s", e.getMessage()); diff --git a/src/android/NanoHTTPD.java b/src/android/NanoHTTPD.java index d249309..f9f6647 100644 --- a/src/android/NanoHTTPD.java +++ b/src/android/NanoHTTPD.java @@ -9,6 +9,7 @@ import java.io.OutputStream; import java.io.PrintStream; import java.io.PrintWriter; +import java.lang.InterruptedException; import java.net.InetSocketAddress; import java.net.ServerSocket; import java.net.Socket; @@ -427,7 +428,7 @@ else if (splitbyte==0 || size == 0x7FFFFFFFFFFFFFFFl) // If the method is POST, there may be parameters // in data section, too, read it: - if ( method.equalsIgnoreCase( "POST" )) + if ( "POST".equalsIgnoreCase( method )) { String contentType = ""; String contentTypeHeader = header.getProperty("content-type"); @@ -466,7 +467,7 @@ else if (splitbyte==0 || size == 0x7FFFFFFFFFFFFFFFl) } } - if ( method.equalsIgnoreCase( "PUT" )) + if ( "PUT".equalsIgnoreCase( method )) files.put("content", saveTmpFile( fbuf, 0, f.size())); // Ok, now do the serve() @@ -832,15 +833,18 @@ private void sendResponse( String status, String mime, Properties header, InputS if ( data != null ) { - int pending = data.available(); // This is to support partial sends, see serveFile() byte[] buff = new byte[theBufferSize]; - while (pending>0) + int read; + while ((read = data.read( buff, 0, theBufferSize)) != -1 ) { - int read = data.read( buff, 0, ( (pending>theBufferSize) ? theBufferSize : pending )); - if (read <= 0) break; - out.write( buff, 0, read ); - pending -= read; - } + if (read == 0) { + try { + Thread.sleep(50); + } catch (InterruptedException e) {} + } else { + out.write(buff, 0, read); + } + } } out.flush(); out.close(); diff --git a/src/android/WebServer.java b/src/android/WebServer.java index 0c35ca3..405409f 100644 --- a/src/android/WebServer.java +++ b/src/android/WebServer.java @@ -1,15 +1,168 @@ package com.rjfun.cordova.httpd; +import java.io.BufferedInputStream; +import java.io.InputStream; import java.io.IOException; +import java.lang.Override; +import java.lang.String; +import java.net.MalformedURLException; import java.net.InetSocketAddress; +import java.net.HttpURLConnection; +import java.net.URL; +import java.util.Arrays; +import java.util.Comparator; +import java.util.Iterator; +import java.util.Map; +import java.util.Properties; + + +import android.util.Log; public class WebServer extends NanoHTTPD { - public WebServer(InetSocketAddress localAddr, AndroidFile wwwroot) throws IOException { + private Map customPaths = null; + private String[] customURIs = new String[0]; + private final String LOGTAG = "NanoHTTPD-Cordova"; + + public WebServer(InetSocketAddress localAddr, AndroidFile wwwroot, Map customPaths ) throws IOException { super(localAddr, wwwroot); + addCustomPaths(customPaths); } - public WebServer(int port, AndroidFile wwwroot ) throws IOException { + public WebServer(int port, AndroidFile wwwroot, Map customPaths ) throws IOException { super(port, wwwroot); + addCustomPaths(customPaths); } + + private void addCustomPaths(Map customPaths) { + this.customPaths = customPaths; + customURIs = new String[customPaths.keySet().size()]; + int i = 0; + Iterator keys = customPaths.keySet().iterator(); + while (keys.hasNext()) { + String path = (String) keys.next(); + customURIs[i] = path; + i++; + } + Arrays.sort(customURIs, new Comparator() { + @Override + public int compare(String s, String t1) { + return t1.length() - s.length(); + } + }); + for (i = 0; i < customURIs.length; i++) { + Log.i( LOGTAG, "Custom Path: " + customURIs[i]); + } + } + + public Response serve( String uri, String method, Properties header, Properties parms, Properties files ) + { + if (uri == null || method == null) { + return null; + } + Log.i( LOGTAG, method + " '" + uri + "' " ); + for (int i = 0; i < customURIs.length; i++) { + String testURI = customURIs[i]; + if (uri.startsWith(testURI)) { + Log.i( LOGTAG, method + " '" + uri + "' " ); + String newURI = uri.substring(testURI.length()); + Object customPath = customPaths.get(testURI); + if (customPath instanceof String) { + URL url = null; + HttpURLConnection connection = null; + InputStream in = null; + + // Open the HTTP connection + try { + url = new URL(((String) customPath) + newURI); + connection = (HttpURLConnection) url.openConnection(); + connection.connect(); + } catch (MalformedURLException e) { + e.printStackTrace(); + return null; + } catch (IOException e) { + e.printStackTrace(); + } + try { + in = new InputStreamWithOverloadedClose(connection.getInputStream(), connection); + } catch (IOException e) { + e.printStackTrace(); + } + String datatype = connection.getContentType(); //NanoHTTPD.MIME_DEFAULT_BINARY + Response response = new NanoHTTPD.Response(NanoHTTPD.HTTP_OK, datatype, in); + if (connection.getContentEncoding() != null) + response.addHeader("Content-Encoding", connection.getContentEncoding()); + if (connection.getContentLength() != -1) + response.addHeader("Content-Length", "" + connection.getContentLength()); + if (connection.getHeaderField("Date") != null) + response.addHeader("Date", connection.getHeaderField("Date")); + if (connection.getHeaderField("Last-Modified") != null) + response.addHeader("Last-Modified", connection.getHeaderField("Last-Modified")); + if (connection.getHeaderField("Cache-Control") != null) + response.addHeader("Cache-Control", connection.getHeaderField("Cache-Control")); + return response; + } else { + return serveFile( newURI, header, (AndroidFile) customPath, true ); + } + } + } + return super.serve( uri, method, header, parms, files ); + } + + public class InputStreamWithOverloadedClose extends InputStream { + protected InputStream is; + protected HttpURLConnection connection; + + public InputStreamWithOverloadedClose(InputStream is, HttpURLConnection connection) { + super(); + this.is = is; + this.connection = connection; + } + + @Override + public int available() throws IOException { + return is.available(); + } + + @Override + public void close() throws IOException { + is.close(); + connection.disconnect(); + } + + @Override + public void mark(int readlimit) { + is.mark(readlimit); + } + + @Override + public boolean markSupported() { + return is.markSupported(); + } + + @Override + public int read() throws IOException { + return is.read(); + } + + @Override + public int read(byte[] buffer) throws IOException { + return is.read(buffer); + } + + @Override + public int read(byte[] buffer, int offset, int length) throws IOException { + return is.read(buffer, offset, length); + } + + @Override + public synchronized void reset() throws IOException { + is.reset(); + } + + @Override + public long skip(long byteCount) throws IOException { + return is.skip(byteCount); + } + } } diff --git a/src/ios/CorHttpd.m b/src/ios/CorHttpd.m index 3a1b0c4..fe0b908 100644 --- a/src/ios/CorHttpd.m +++ b/src/ios/CorHttpd.m @@ -5,6 +5,7 @@ #import "DDLog.h" #import "DDTTYLogger.h" #import "HTTPServer.h" +#import "CustomPathHTTPConnection.h" @interface CorHttpd : CDVPlugin { // Member variables go here. @@ -14,6 +15,7 @@ @interface CorHttpd : CDVPlugin { @property(nonatomic, retain) HTTPServer *httpServer; @property(nonatomic, retain) NSString *localPath; @property(nonatomic, retain) NSString *url; +@property(nonatomic, retain) NSMutableDictionary *customPaths; @property (nonatomic, retain) NSString* www_root; @property (assign) int port; @@ -43,6 +45,7 @@ @implementation CorHttpd #define OPT_WWW_ROOT @"www_root" #define OPT_PORT @"port" #define OPT_LOCALHOST_ONLY @"localhost_only" +#define OPT_CUSTOM_PATHS @"custom_paths" #define IP_LOCALHOST @"127.0.0.1" #define IP_ANY @"0.0.0.0" @@ -142,6 +145,31 @@ - (void)startServer:(CDVInvokedUrlCommand*)command [DDLog addLogger:[DDTTYLogger sharedInstance]]; self.httpServer = [[HTTPServer alloc] init]; + [self.httpServer setConnectionClass:[CustomPathHTTPConnection class]]; + + NSDictionary* customPathsFromOptions = (NSDictionary *)[options valueForKey:OPT_CUSTOM_PATHS]; + if (customPathsFromOptions == nil) { + self.customPaths = [NSMutableDictionary dictionaryWithCapacity:0]; + } else { + self.customPaths = [NSMutableDictionary dictionaryWithCapacity:[customPathsFromOptions count]]; + [customPathsFromOptions enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) + { + NSString* customPath = (NSString*) key; + NSString* path = (NSString*) obj; + NSString* localPath = nil; + const char * docroot = [path UTF8String]; + if(*docroot == '/' || [path hasPrefix:@"http://"] || [path hasPrefix:@"https://"]) { + localPath = path; + } else { + NSString* basePath = [[[NSBundle mainBundle] resourcePath] stringByAppendingPathComponent:@"www"]; + localPath = [NSString stringWithFormat:@"%@/%@", basePath, path]; + } + NSLog(@"Custom Path: %@ - %@", customPath, localPath); + [self.customPaths setObject:localPath forKey:customPath]; + }]; + } + [CustomPathHTTPConnection setCustomPaths:self.customPaths]; + // Tell the server to broadcast its presence via Bonjour. // This allows browsers such as Safari to automatically discover our service. //[self.httpServer setType:@"_http._tcp."]; diff --git a/src/ios/CustomPathHTTPConnection.h b/src/ios/CustomPathHTTPConnection.h new file mode 100644 index 0000000..4f5159b --- /dev/null +++ b/src/ios/CustomPathHTTPConnection.h @@ -0,0 +1,17 @@ +#import "HTTPConnection.h" +#import "HTTPDataResponse.h" + +@interface CustomPathHTTPConnection : HTTPConnection ++ (NSDictionary *) customPaths; ++ (void) setCustomPaths:(NSDictionary *) cusPaths; +- (NSString *)filePathForURI:(NSString *)path allowDirectory:(BOOL)allowDirectory documentRoot:(NSString *) documentRoot; +- (NSObject *)httpResponseForMethod:(NSString *)method URI:(NSString *)path; +@end + +@interface HTTPDataResponseWithHeaders : HTTPDataResponse +{ + NSDictionary * headers; +} +- (id)initWithDataAndHeaders:(NSData *)data httpHeaders:(NSDictionary *) httpHeaders; +- (NSDictionary *)httpHeaders; +@end \ No newline at end of file diff --git a/src/ios/CustomPathHTTPConnection.m b/src/ios/CustomPathHTTPConnection.m new file mode 100644 index 0000000..3a94335 --- /dev/null +++ b/src/ios/CustomPathHTTPConnection.m @@ -0,0 +1,197 @@ +#import +#import "CustomPathHTTPConnection.h" +#import "HTTPConnection.h" +#import "HTTPLogging.h" +#import "HTTPResponse.h" +#import "HTTPErrorResponse.h" +#import "HTTPDataResponse.h" + +@implementation CustomPathHTTPConnection : HTTPConnection +static const int httpLogLevel = HTTP_LOG_LEVEL_WARN; // | HTTP_LOG_FLAG_TRACE; +static NSDictionary * customPaths = nil; ++ (NSDictionary *) customPaths { @synchronized(self) { return customPaths; } } ++ (void) setCustomPaths:(NSDictionary *) cusPaths { @synchronized(self) { customPaths = cusPaths; } } + +/** + * This method is called to get a response for a request. + * You may return any object that adopts the HTTPResponse protocol. + * The HTTPServer comes with two such classes: HTTPFileResponse and HTTPDataResponse. + * HTTPFileResponse is a wrapper for an NSFileHandle object, and is the preferred way to send a file response. + * HTTPDataResponse is a wrapper for an NSData object, and may be used to send a custom response. + **/ +- (NSObject *)httpResponseForMethod:(NSString *)method URI:(NSString *)path +{ + HTTPLogTrace(); + + __block NSObject * httpResponseReturnable = nil; + + /* + * The following block of code determins if the URL you have requested should be backended to the named + * URL in the custom path. If it does, we'll make a synchronous request for it, and generate a + * HTTPDataResponse object and pass that back. If the backend throws any errors, we'll generate a + * HTTPErrorRepsonse object with the status code, and pass that back. If none of these match, we'll + * allow the existing code to happen. + */ + [CustomPathHTTPConnection.customPaths enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) + { + NSString* customPath = (NSString*) key; + if ([path hasPrefix:customPath] && ([obj hasPrefix:@"http://"] || [obj hasPrefix:@"https://"])) { + *stop = YES; + NSString* subPath = [path substringFromIndex:[customPath length]]; + NSURL *realURL = [NSURL URLWithString: [(NSString *) obj stringByAppendingString: subPath]]; + // turn it into a request and use NSData to load its content + NSURLRequest *urlRequest = [NSURLRequest requestWithURL:realURL]; + NSHTTPURLResponse * urlResponse = nil; + NSError * error = nil; + NSData * data = [NSURLConnection sendSynchronousRequest:urlRequest returningResponse:&urlResponse error:&error]; + + if (error != nil) { + httpResponseReturnable = [[HTTPErrorResponse alloc] initWithErrorCode: (int) urlResponse.statusCode]; + } else { + httpResponseReturnable = [[HTTPDataResponseWithHeaders alloc] initWithDataAndHeaders: data httpHeaders:urlResponse.allHeaderFields]; + } + } + }]; + + if (httpResponseReturnable != nil) + { + return httpResponseReturnable; + } + else + { + return [super httpResponseForMethod:path URI:path]; + } + +} + + +- (NSString *)filePathForURI:(NSString *)path allowDirectory:(BOOL)allowDirectory +{ + HTTPLogTrace(); + __block NSString *pathForURI = nil; + [CustomPathHTTPConnection.customPaths enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) + { + NSString* customPath = (NSString*) key; + if ([path hasPrefix:customPath]) { + *stop = YES; + NSString* subPath = [path substringFromIndex:[customPath length]]; + pathForURI = [self filePathForURI:subPath allowDirectory:allowDirectory documentRoot:(NSString *) obj]; + } + }]; + if (pathForURI != nil) { + return pathForURI; + } else { + return [super filePathForURI:path allowDirectory:allowDirectory]; + } +} + +- (NSString *)filePathForURI:(NSString *)path allowDirectory:(BOOL)allowDirectory documentRoot:(NSString *) documentRoot +{ + // Part 0: Validate document root setting. + // + // If there is no configured documentRoot, + // then it makes no sense to try to return anything. + + if (documentRoot == nil) + { + HTTPLogWarn(@"%@[%p]: No configured document root", THIS_FILE, self); + return nil; + } + + // Part 1: Strip parameters from the url + // + // E.g.: /page.html?q=22&var=abc -> /page.html + + NSURL *docRoot = [NSURL fileURLWithPath:documentRoot isDirectory:YES]; + if (docRoot == nil) + { + HTTPLogWarn(@"%@[%p]: Document root is invalid file path", THIS_FILE, self); + return nil; + } + + NSString *relativePath = [[NSURL URLWithString:path relativeToURL:docRoot] relativePath]; + + // Part 2: Append relative path to document root (base path) + // + // E.g.: relativePath="/images/icon.png" + // documentRoot="/Users/robbie/Sites" + // fullPath="/Users/robbie/Sites/images/icon.png" + // + // We also standardize the path. + // + // E.g.: "Users/robbie/Sites/images/../index.html" -> "/Users/robbie/Sites/index.html" + + NSString *fullPath = [[documentRoot stringByAppendingPathComponent:relativePath] stringByStandardizingPath]; + + if ([relativePath isEqualToString:@"/"]) + { + fullPath = [fullPath stringByAppendingString:@"/"]; + } + + // Part 3: Prevent serving files outside the document root. + // + // Sneaky requests may include ".." in the path. + // + // E.g.: relativePath="../Documents/TopSecret.doc" + // documentRoot="/Users/robbie/Sites" + // fullPath="/Users/robbie/Documents/TopSecret.doc" + // + // E.g.: relativePath="../Sites_Secret/TopSecret.doc" + // documentRoot="/Users/robbie/Sites" + // fullPath="/Users/robbie/Sites_Secret/TopSecret" + + if (![documentRoot hasSuffix:@"/"]) + { + documentRoot = [documentRoot stringByAppendingString:@"/"]; + } + + if (![fullPath hasPrefix:documentRoot]) + { + HTTPLogWarn(@"%@[%p]: Request for file outside document root", THIS_FILE, self); + return nil; + } + + // Part 4: Search for index page if path is pointing to a directory + if (!allowDirectory) + { + BOOL isDir = NO; + if ([[NSFileManager defaultManager] fileExistsAtPath:fullPath isDirectory:&isDir] && isDir) + { + NSArray *indexFileNames = [self directoryIndexFileNames]; + + for (NSString *indexFileName in indexFileNames) + { + NSString *indexFilePath = [fullPath stringByAppendingPathComponent:indexFileName]; + + if ([[NSFileManager defaultManager] fileExistsAtPath:indexFilePath isDirectory:&isDir] && !isDir) + { + return indexFilePath; + } + } + + // No matching index files found in directory + return nil; + } + } + + return fullPath; +} +@end + +@implementation HTTPDataResponseWithHeaders : HTTPDataResponse +- (id)initWithDataAndHeaders:(NSData *)dataToUse httpHeaders:(NSDictionary *) httpHeaders +{ + if((self = [super initWithData: dataToUse])) + { + headers = [NSMutableDictionary dictionaryWithDictionary:httpHeaders]; + [(NSMutableDictionary *) headers removeObjectForKey:@"Content-Encoding"]; + [(NSMutableDictionary *) headers removeObjectForKey:@"Content-Length"]; + } + return self; +} + +- (NSDictionary *)httpHeaders +{ + return headers; +} +@end \ No newline at end of file diff --git a/www/CorHttpd.js b/www/CorHttpd.js index 0c6f82c..9ae58d2 100644 --- a/www/CorHttpd.js +++ b/www/CorHttpd.js @@ -8,7 +8,8 @@ corhttpd_exports.startServer = function(options, success, error) { var defaults = { 'www_root': '', 'port': 8888, - 'localhost_only': false + 'localhost_only': false, + 'custom_paths': {} }; // Merge optional settings into defaults.