blob: 7f5fae75bc4dee64baf58831ba2bf50a50a5af10 [file] [log] [blame]
// Copyright (c) 2013, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
library http_server;
import 'dart:async';
import 'dart:io';
import 'dart:convert' show
HtmlEscape;
import 'path.dart';
import 'test_suite.dart'; // For TestUtils.
// TODO(efortuna): Rewrite to not use the args library and simply take an
// expected number of arguments, so test.dart doesn't rely on the args library?
// See discussion on https://codereview.chromium.org/11931025/.
import 'vendored_pkg/args/args.dart';
import 'utils.dart';
class DispatchingServer {
HttpServer server;
Map<String, Function> _handlers = new Map<String, Function>();
Function _notFound;
DispatchingServer(this.server,
void onError(e),
void this._notFound(HttpRequest request)) {
server.listen(_dispatchRequest, onError: onError);
}
void addHandler(String prefix, void handler(HttpRequest request)) {
_handlers[prefix] = handler;
}
void _dispatchRequest(HttpRequest request) {
// If the request path matches a prefix in _handlers, send it to that
// handler. Otherwise, run the notFound handler.
for (String prefix in _handlers.keys) {
if (request.uri.path.startsWith(prefix)) {
_handlers[prefix](request);
return;
}
}
_notFound(request);
}
}
/// Interface of the HTTP server:
///
/// /echo: This will stream the data received in the request stream back
/// to the client.
/// /root_dart/X: This will serve the corresponding file from the dart
/// directory (i.e. '$DartDirectory/X').
/// /root_build/X: This will serve the corresponding file from the build
/// directory (i.e. '$BuildDirectory/X').
/// /FOO/packages/BAR: This will serve the corresponding file from the packages
/// directory (i.e. '$BuildDirectory/packages/BAR') or the
/// passed-in package root
/// /ws: This will upgrade the connection to a WebSocket connection and echo
/// all data back to the client.
///
/// In case a path does not refer to a file but rather to a directory, a
/// directory listing will be displayed.
const PREFIX_BUILDDIR = 'root_build';
const PREFIX_DARTDIR = 'root_dart';
// TODO(kustermann,ricow): We could change this to the following scheme:
// http://host:port/root_packages/X -> $BuildDir/packages/X
// Issue: 8368
main(List<String> arguments) {
// This script is in [dart]/tools/testing/dart.
TestUtils.setDartDirUri(Platform.script.resolve('../../..'));
/** Convenience method for local testing. */
var parser = new ArgParser();
parser.addOption('port', abbr: 'p',
help: 'The main server port we wish to respond to requests.',
defaultsTo: '0');
parser.addOption('crossOriginPort', abbr: 'c',
help: 'A different port that accepts request from the main server port.',
defaultsTo: '0');
parser.addFlag('help', abbr: 'h', negatable: false,
help: 'Print this usage information.');
parser.addOption('build-directory', help: 'The build directory to use.');
parser.addOption('package-root', help: 'The package root to use.');
parser.addOption('network', help: 'The network interface to use.',
defaultsTo: '0.0.0.0');
parser.addFlag('csp', help: 'Use Content Security Policy restrictions.',
defaultsTo: false);
parser.addOption('runtime', help: 'The runtime we are using (for csp flags).',
defaultsTo: 'none');
var args = parser.parse(arguments);
if (args['help']) {
print(parser.getUsage());
} else {
var servers = new TestingServers(new Path(args['build-directory']),
args['csp'],
args['runtime'],
null,
args['package-root']);
var port = int.parse(args['port']);
var crossOriginPort = int.parse(args['crossOriginPort']);
servers.startServers(args['network'],
port: port,
crossOriginPort: crossOriginPort).then((_) {
DebugLogger.info('Server listening on port ${servers.port}');
DebugLogger.info('Server listening on port ${servers.crossOriginPort}');
});
}
}
/**
* Runs a set of servers that are initialized specifically for the needs of our
* test framework, such as dealing with package-root.
*/
class TestingServers {
static final _CACHE_EXPIRATION_IN_SECONDS = 30;
static final _HARMLESS_REQUEST_PATH_ENDINGS = [
"/apple-touch-icon.png",
"/apple-touch-icon-precomposed.png",
"/favicon.ico",
"/foo",
"/bar",
"/NonExistingFile",
"IntentionallyMissingFile",
];
List _serverList = [];
Path _buildDirectory = null;
Path _dartDirectory = null;
Path _packageRoot;
final bool useContentSecurityPolicy;
final String runtime;
DispatchingServer _server;
TestingServers(Path buildDirectory,
this.useContentSecurityPolicy,
[String this.runtime = 'none', String dartDirectory,
String packageRoot]) {
_buildDirectory = TestUtils.absolutePath(buildDirectory);
_dartDirectory = dartDirectory == null ? TestUtils.dartDir
: new Path(dartDirectory);
_packageRoot = packageRoot == null ?
_buildDirectory.append('packages') :
new Path(packageRoot);
}
int get port => _serverList[0].port;
int get crossOriginPort => _serverList[1].port;
DispatchingServer get server => _server;
/**
* [startServers] will start two Http servers.
* The first server listens on [port] and sets
* "Access-Control-Allow-Origin: *"
* The second server listens on [crossOriginPort] and sets
* "Access-Control-Allow-Origin: client:port1
* "Access-Control-Allow-Credentials: true"
*/
Future startServers(String host, {int port: 0, int crossOriginPort: 0}) {
return _startHttpServer(host, port: port).then((server) {
_server = server;
return _startHttpServer(host,
port: crossOriginPort,
allowedPort:_serverList[0].port);
});
}
String httpServerCommandline() {
var dart = TestUtils.dartTestExecutable.toNativePath();
var dartDir = TestUtils.dartDir;
var script = dartDir.join(new Path("tools/testing/dart/http_server.dart"));
var buildDirectory = _buildDirectory.toNativePath();
var csp = useContentSecurityPolicy ? '--csp ' : '';
return '$dart $script -p $port -c $crossOriginPort $csp'
'--build-directory=$buildDirectory --runtime=$runtime '
'--package-root=$_packageRoot';
}
void stopServers() {
for (var server in _serverList) {
server.close();
}
}
void _onError(e) {
DebugLogger.error('HttpServer: an error occured', e);
}
Future _startHttpServer(String host, {int port: 0, int allowedPort: -1}) {
return HttpServer.bind(host, port).then((HttpServer httpServer) {
var server = new DispatchingServer(httpServer, _onError, _sendNotFound);
server.addHandler('/echo', _handleEchoRequest);
server.addHandler('/ws', _handleWebSocketRequest);
fileHandler(request) {
_handleFileOrDirectoryRequest(request, allowedPort);
}
server.addHandler('/$PREFIX_BUILDDIR', fileHandler);
server.addHandler('/$PREFIX_DARTDIR', fileHandler);
server.addHandler('/packages', fileHandler);
_serverList.add(httpServer);
return server;
});
}
void _handleFileOrDirectoryRequest(HttpRequest request,
int allowedPort) {
// Enable browsers to cache file/directory responses.
var response = request.response;
response.headers.set("Cache-Control",
"max-age=$_CACHE_EXPIRATION_IN_SECONDS");
var path = _getFilePathFromRequestPath(request.uri.path);
if (path != null) {
var file = new File(path.toNativePath());
file.exists().then((exists) {
if (exists) {
_sendFileContent(request, response, allowedPort, path, file);
} else {
var directory = new Directory(path.toNativePath());
directory.exists().then((exists) {
if (exists) {
_listDirectory(directory).then((entries) {
_sendDirectoryListing(entries, request, response);
});
} else {
_sendNotFound(request);
}
});
}
});
} else {
if (request.uri.path == '/') {
var entries = [new _Entry('root_dart', 'root_dart/'),
new _Entry('root_build', 'root_build/'),
new _Entry('echo', 'echo')];
_sendDirectoryListing(entries, request, response);
} else {
_sendNotFound(request);
}
}
}
void _handleEchoRequest(HttpRequest request) {
request.response.headers.set("Access-Control-Allow-Origin", "*");
request.pipe(request.response).catchError((e) {
DebugLogger.warning(
'HttpServer: error while closing the response stream', e);
});
}
void _handleWebSocketRequest(HttpRequest request) {
WebSocketTransformer.upgrade(request).then((websocket) {
// We ignore failures to write to the socket, this happens if the browser
// closes the connection.
websocket.done.catchError((_) {});
websocket.listen((data) {
websocket.add(data);
websocket.close();
}, onError: (e) {
DebugLogger.warning('HttpServer: error while echoing to WebSocket', e);
});
}).catchError((e) {
DebugLogger.warning(
'HttpServer: error while transforming to WebSocket', e);
});
}
Path _getFilePathFromRequestPath(String urlRequestPath) {
// Go to the top of the file to see an explanation of the URL path scheme.
var requestPath = new Path(urlRequestPath.substring(1)).canonicalize();
var pathSegments = requestPath.segments();
if (pathSegments.length > 0) {
var basePath;
var relativePath;
if (pathSegments[0] == PREFIX_BUILDDIR) {
basePath = _buildDirectory;
relativePath = new Path(
pathSegments.skip(1).join('/'));
} else if (pathSegments[0] == PREFIX_DARTDIR) {
basePath = _dartDirectory;
relativePath = new Path(
pathSegments.skip(1).join('/'));
}
var packagesIndex = pathSegments.indexOf('packages');
if (packagesIndex != -1) {
var start = packagesIndex + 1;
basePath = _packageRoot;
relativePath = new Path(pathSegments.skip(start).join('/'));
}
if (basePath != null && relativePath != null) {
return basePath.join(relativePath);
}
}
return null;
}
Future<List<_Entry>> _listDirectory(Directory directory) {
var completer = new Completer();
var entries = [];
directory.list().listen(
(FileSystemEntity fse) {
var filename = new Path(fse.path).filename;
if (fse is File) {
entries.add(new _Entry(filename, filename));
} else if (fse is Directory) {
entries.add(new _Entry(filename, '$filename/'));
}
},
onDone: () {
completer.complete(entries);
});
return completer.future;
}
void _sendDirectoryListing(List<_Entry> entries,
HttpRequest request,
HttpResponse response) {
response.headers.set('Content-Type', 'text/html');
var header = '''<!DOCTYPE html>
<html>
<head>
<title>${request.uri.path}</title>
</head>
<body>
<code>
<div>${request.uri.path}</div>
<hr/>
<ul>''';
var footer = '''
</ul>
</code>
</body>
</html>''';
entries.sort();
response.write(header);
for (var entry in entries) {
response.write(
'<li><a href="${new Path(request.uri.path).append(entry.name)}">'
'${entry.displayName}</a></li>');
}
response.write(footer);
response.close();
response.done.catchError((e) {
DebugLogger.warning(
'HttpServer: error while closing the response stream', e);
});
}
void _sendFileContent(HttpRequest request,
HttpResponse response,
int allowedPort,
Path path,
File file) {
if (allowedPort != -1) {
var headerOrigin = request.headers.value('Origin');
var allowedOrigin;
if (headerOrigin != null) {
var origin = Uri.parse(headerOrigin);
// Allow loading from http://*:$allowedPort in browsers.
allowedOrigin =
'${origin.scheme}://${origin.host}:${allowedPort}';
} else {
// IE10 appears to be bugged and is not sending the Origin header
// when making CORS requests to the same domain but different port.
allowedOrigin = '*';
}
response.headers.set("Access-Control-Allow-Origin", allowedOrigin);
response.headers.set('Access-Control-Allow-Credentials', 'true');
} else {
// No allowedPort specified. Allow from anywhere (but cross-origin
// requests *with credentials* will fail because you can't use "*").
response.headers.set("Access-Control-Allow-Origin", "*");
}
if (useContentSecurityPolicy) {
// Chrome respects the standardized Content-Security-Policy header,
// whereas Firefox and IE10 use X-Content-Security-Policy. Safari
// still uses the WebKit- prefixed version.
var content_header_value = "script-src 'self'; object-src 'self'";
for (var header in ["Content-Security-Policy",
"X-Content-Security-Policy"]) {
response.headers.set(header, content_header_value);
}
if (const ["safari"].contains(runtime)) {
response.headers.set("X-WebKit-CSP", content_header_value);
}
}
if (path.filename.endsWith('.html')) {
response.headers.set('Content-Type', 'text/html');
} else if (path.filename.endsWith('.js')) {
response.headers.set('Content-Type', 'application/javascript');
} else if (path.filename.endsWith('.dart')) {
response.headers.set('Content-Type', 'application/dart');
} else if (path.filename.endsWith('.css')) {
response.headers.set('Content-Type', 'text/css');
} else if (path.filename.endsWith('.xml')) {
response.headers.set('Content-Type', 'text/xml');
}
response.headers.removeAll("X-Frame-Options");
file.openRead().pipe(response).catchError((e) {
DebugLogger.warning(
'HttpServer: error while closing the response stream', e);
});
}
void _sendNotFound(HttpRequest request) {
bool isHarmlessPath(String path) {
return _HARMLESS_REQUEST_PATH_ENDINGS.any((pattern) {
return path.contains(pattern);
});
}
if (!isHarmlessPath(request.uri.path)) {
DebugLogger.warning('HttpServer: could not find file for request path: '
'"${request.uri.path}"');
}
var response = request.response;
response.statusCode = HttpStatus.NOT_FOUND;
// Send a nice HTML page detailing the error message. Most browsers expect
// this, for example, Chrome will simply display a blank page if you don't
// provide any information. A nice side effect of this is to work around
// Firefox bug 1016313
// (https://bugzilla.mozilla.org/show_bug.cgi?id=1016313).
response.headers.set(HttpHeaders.CONTENT_TYPE, 'text/html');
String escapedPath = const HtmlEscape().convert(request.uri.path);
response.write("""
<!DOCTYPE html>
<html lang='en'>
<head>
<title>Not Found</title>
</head>
<body>
<h1>Not Found</h1>
<p style='white-space:pre'>The file '$escapedPath\' could not be found.</p>
</body>
</html>
""");
response.close();
response.done.catchError((e) {
DebugLogger.warning(
'HttpServer: error while closing the response stream', e);
});
}
}
// Helper class for displaying directory listings.
class _Entry implements Comparable {
final String name;
final String displayName;
_Entry(this.name, this.displayName);
int compareTo(_Entry other) {
return name.compareTo(other.name);
}
}