461 lines
15 KiB
Dart
461 lines
15 KiB
Dart
// 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.
|
|
|
|
import 'dart:async';
|
|
import 'dart:convert';
|
|
import 'dart:io';
|
|
|
|
import 'package:platform_http_server/src/has_current_iterator.dart';
|
|
import 'package:mime/mime.dart';
|
|
import 'package:path/path.dart';
|
|
|
|
// Used for signal a directory redirecting, where a tailing slash is missing.
|
|
class _DirectoryRedirect {
|
|
const _DirectoryRedirect();
|
|
}
|
|
|
|
/// A [VirtualDirectory] can serve files and directory-listing from a root path,
|
|
/// to [HttpRequest]s.
|
|
///
|
|
/// The [VirtualDirectory] providing secure handling of request uris and
|
|
/// file-system links, correct mime-types and custom error pages.
|
|
class VirtualDirectory {
|
|
final String root;
|
|
|
|
/// Whether to allow listing files in a directories.
|
|
///
|
|
/// When true the response to a request for a directory will be an HTML
|
|
/// document with a table of links to all files within the directory, along
|
|
/// with their size and last modified time. The default behavior can be
|
|
/// overridden by setting a [directoryHandler].
|
|
bool allowDirectoryListing = false;
|
|
|
|
/// Whether to allow reading resources via a link.
|
|
bool followLinks = true;
|
|
|
|
/// Whether to prevent access outside of [root] via relative paths or links.
|
|
bool jailRoot = true;
|
|
|
|
final List<String> _pathPrefixSegments;
|
|
|
|
final RegExp _invalidPathRegExp = RegExp('[\\/\x00]');
|
|
|
|
void Function(HttpRequest)? _errorCallback;
|
|
void Function(Directory, HttpRequest)? _dirCallback;
|
|
|
|
static List<String> _parsePathPrefix(String? pathPrefix) {
|
|
if (pathPrefix == null) return <String>[];
|
|
return Uri(path: pathPrefix)
|
|
.pathSegments
|
|
.where((segment) => segment.isNotEmpty)
|
|
.toList();
|
|
}
|
|
|
|
/// Create a new [VirtualDirectory] for serving static file content of the
|
|
/// path [root].
|
|
///
|
|
/// The [root] is not required to exist. If the [root] doesn't exist at time of
|
|
/// a request, a 404 response is generated.
|
|
///
|
|
/// If [pathPrefix] is set, [pathPrefix] will indicate the expected path prefix
|
|
/// of incoming requests. When locating the resource on disk, the prefix will
|
|
/// be trimmed from the requests uri, before locating the actual resource.
|
|
/// If the requests uri doesn't start with [pathPrefix], a 404 response is
|
|
/// generated.
|
|
VirtualDirectory(this.root, {String? pathPrefix})
|
|
: _pathPrefixSegments = _parsePathPrefix(pathPrefix);
|
|
|
|
/// Serve a [Stream] of [HttpRequest]s, in this [VirtualDirectory].
|
|
StreamSubscription<HttpRequest> serve(Stream<HttpRequest> requests) =>
|
|
requests.listen(serveRequest);
|
|
|
|
/// Serve a single [HttpRequest], in this [VirtualDirectory].
|
|
Future serveRequest(HttpRequest request) async {
|
|
var iterator = HasCurrentIterator(request.uri.pathSegments.iterator);
|
|
iterator.moveNext();
|
|
for (var segment in _pathPrefixSegments) {
|
|
if (!iterator.hasCurrent || iterator.current != segment) {
|
|
_serveErrorPage(HttpStatus.notFound, request);
|
|
return request.response.done;
|
|
}
|
|
iterator.moveNext();
|
|
}
|
|
|
|
var entity = await _locateResource('.', iterator);
|
|
if (entity is File) {
|
|
serveFile(entity, request);
|
|
} else if (entity is Directory) {
|
|
if (allowDirectoryListing) {
|
|
_serveDirectory(entity, request);
|
|
} else {
|
|
_serveErrorPage(HttpStatus.notFound, request);
|
|
}
|
|
} else if (entity is _DirectoryRedirect) {
|
|
_unawaited(request.response.redirect(Uri.parse('${request.uri}/'),
|
|
status: HttpStatus.movedPermanently));
|
|
} else {
|
|
assert(entity == null);
|
|
_serveErrorPage(HttpStatus.notFound, request);
|
|
}
|
|
return request.response.done;
|
|
}
|
|
|
|
/// Overrides the default directory listing.
|
|
///
|
|
/// When invoked the [callback] should response through the [HttpRequest] with
|
|
/// a directory listing.
|
|
set directoryHandler(void Function(Directory, HttpRequest) callback) {
|
|
_dirCallback = callback;
|
|
}
|
|
|
|
/// Overrides the default error handle.
|
|
///
|
|
/// When [callback] is invoked, the `statusCode` property of the response is
|
|
/// set.
|
|
set errorPageHandler(void Function(HttpRequest) callback) {
|
|
_errorCallback = callback;
|
|
}
|
|
|
|
Future<Object?> _locateResource(
|
|
String path, HasCurrentIterator<String> segments) async {
|
|
// Don't allow navigating up paths.
|
|
if (segments.hasCurrent && segments.current == '..') {
|
|
return Future.value(null);
|
|
}
|
|
path = normalize(path);
|
|
// If we jail to root, the relative path can never go up.
|
|
if (jailRoot && split(path).first == '..') return Future.value(null);
|
|
String fullPath() => join(root, path);
|
|
var type = await FileSystemEntity.type(fullPath(), followLinks: false);
|
|
switch (type) {
|
|
case FileSystemEntityType.file:
|
|
if (!segments.hasCurrent) {
|
|
return File(fullPath());
|
|
}
|
|
break;
|
|
|
|
case FileSystemEntityType.directory:
|
|
String dirFullPath() => '${fullPath()}$separator';
|
|
if (!segments.hasCurrent) {
|
|
if (path == '.') return Directory(dirFullPath());
|
|
return const _DirectoryRedirect();
|
|
}
|
|
var current = segments.current;
|
|
var hasNext = segments.moveNext();
|
|
if (!hasNext && current == '') {
|
|
return Directory(dirFullPath());
|
|
} else {
|
|
if (_invalidPathRegExp.hasMatch(current)) break;
|
|
return _locateResource(join(path, current), segments);
|
|
}
|
|
|
|
case FileSystemEntityType.link:
|
|
if (followLinks) {
|
|
var target = await Link(fullPath()).target();
|
|
var targetPath = normalize(target);
|
|
if (isAbsolute(targetPath)) {
|
|
// If we jail to root, the path can never be absolute.
|
|
if (jailRoot) return null;
|
|
return _locateResource(targetPath, segments);
|
|
} else {
|
|
targetPath = join(dirname(path), targetPath);
|
|
return _locateResource(targetPath, segments);
|
|
}
|
|
}
|
|
break;
|
|
case FileSystemEntityType.notFound:
|
|
break;
|
|
default:
|
|
break;
|
|
}
|
|
// Return `null` on fall-through, to indicate NOT_FOUND.
|
|
return null;
|
|
}
|
|
|
|
/// Serve the content of [file] to [request].
|
|
///
|
|
/// Can be used in overrides of [directoryHandler] to redirect to an index
|
|
/// file.
|
|
///
|
|
/// In the request contains the [HttpHeaders.ifModifiedSince] header,
|
|
/// [serveFile] will send a [HttpStatus.notModified] response if the file
|
|
/// was not changed.
|
|
///
|
|
/// Note that if it was unable to read from [file], the [request]s response
|
|
/// is closed with error-code [HttpStatus.notFound].
|
|
void serveFile(File file, HttpRequest request) async {
|
|
var response = request.response;
|
|
// TODO(ajohnsen): Set up Zone support for these errors.
|
|
try {
|
|
var lastModified = await file.lastModified();
|
|
if (request.headers.ifModifiedSince != null &&
|
|
!lastModified.isAfter(request.headers.ifModifiedSince!)) {
|
|
response.statusCode = HttpStatus.notModified;
|
|
await response.close();
|
|
return null;
|
|
}
|
|
|
|
response.headers.set(HttpHeaders.lastModifiedHeader, lastModified);
|
|
response.headers.set(HttpHeaders.acceptRangesHeader, 'bytes');
|
|
|
|
var length = await file.length();
|
|
var range = request.headers.value(HttpHeaders.rangeHeader);
|
|
if (range != null) {
|
|
// We only support one range, where the standard support several.
|
|
var matches = RegExp(r'^bytes=(\d*)\-(\d*)$').firstMatch(range);
|
|
// If the range header have the right format, handle it.
|
|
if (matches != null &&
|
|
(matches[1]!.isNotEmpty || matches[2]!.isNotEmpty)) {
|
|
// Serve sub-range.
|
|
int start; // First byte position - inclusive.
|
|
int end; // Last byte position - inclusive.
|
|
if (matches[1]!.isEmpty) {
|
|
start = length - int.parse(matches[2]!);
|
|
if (start < 0) start = 0;
|
|
end = length - 1;
|
|
} else {
|
|
start = int.parse(matches[1]!);
|
|
end = matches[2]!.isEmpty ? length - 1 : int.parse(matches[2]!);
|
|
}
|
|
// If the range is syntactically invalid the Range header
|
|
// MUST be ignored (RFC 2616 section 14.35.1).
|
|
if (start <= end) {
|
|
if (end >= length) {
|
|
end = length - 1;
|
|
}
|
|
|
|
if (start >= length) {
|
|
response.statusCode = HttpStatus.requestedRangeNotSatisfiable;
|
|
await response.close();
|
|
return;
|
|
}
|
|
|
|
// Override Content-Length with the actual bytes sent.
|
|
response.headers
|
|
.set(HttpHeaders.contentLengthHeader, end - start + 1);
|
|
|
|
// Set 'Partial Content' status code.
|
|
response
|
|
..statusCode = HttpStatus.partialContent
|
|
..headers.set(
|
|
HttpHeaders.contentRangeHeader, 'bytes $start-$end/$length');
|
|
|
|
// Pipe the 'range' of the file.
|
|
if (request.method == 'HEAD') {
|
|
await response.close();
|
|
} else {
|
|
try {
|
|
await file
|
|
.openRead(start, end + 1)
|
|
.cast<List<int>>()
|
|
.pipe(_VirtualDirectoryFileStream(response, file.path));
|
|
} catch (_) {
|
|
// TODO(kevmoo): log errors
|
|
}
|
|
}
|
|
return;
|
|
}
|
|
}
|
|
}
|
|
|
|
response.headers.set(HttpHeaders.contentLengthHeader, length);
|
|
if (request.method == 'HEAD') {
|
|
await response.close();
|
|
} else {
|
|
try {
|
|
await file
|
|
.openRead()
|
|
.cast<List<int>>()
|
|
.pipe(_VirtualDirectoryFileStream(response, file.path));
|
|
} catch (_) {
|
|
// TODO(kevmoo): log errors
|
|
}
|
|
}
|
|
} catch (_) {
|
|
response.statusCode = HttpStatus.notFound;
|
|
await response.close();
|
|
}
|
|
}
|
|
|
|
void _serveDirectory(Directory dir, HttpRequest request) async {
|
|
if (_dirCallback != null) {
|
|
_dirCallback!(dir, request);
|
|
return;
|
|
}
|
|
var response = request.response;
|
|
try {
|
|
var stats = await dir.stat();
|
|
if (request.headers.ifModifiedSince != null &&
|
|
!stats.modified.isAfter(request.headers.ifModifiedSince!)) {
|
|
response.statusCode = HttpStatus.notModified;
|
|
await response.close();
|
|
return;
|
|
}
|
|
|
|
response.headers.contentType =
|
|
ContentType('text', 'html', parameters: {'charset': 'utf-8'});
|
|
response.headers.set(HttpHeaders.lastModifiedHeader, stats.modified);
|
|
var path = Uri.decodeComponent(request.uri.path);
|
|
var encodedPath = const HtmlEscape().convert(path);
|
|
var header =
|
|
'''<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<title>Index of $encodedPath</title>
|
|
</head>
|
|
<body>
|
|
<h1>Index of $encodedPath</h1>
|
|
<table>
|
|
<tr>
|
|
<td>Name</td>
|
|
<td>Last modified</td>
|
|
<td>Size</td>
|
|
</tr>
|
|
''';
|
|
var server = response.headers.value(HttpHeaders.serverHeader);
|
|
server ??= '';
|
|
var footer = '''</table>
|
|
$server
|
|
</body>
|
|
</html>
|
|
''';
|
|
|
|
response.write(header);
|
|
|
|
void add(String name, String? modified, var size, bool folder) {
|
|
size ??= '-';
|
|
modified ??= '';
|
|
var encodedSize = const HtmlEscape().convert(size.toString());
|
|
var encodedModified = const HtmlEscape().convert(modified);
|
|
var encodedLink = const HtmlEscape(HtmlEscapeMode.attribute)
|
|
.convert(Uri.encodeComponent(name));
|
|
if (folder) {
|
|
encodedLink += '/';
|
|
name += '/';
|
|
}
|
|
var encodedName = const HtmlEscape().convert(name);
|
|
|
|
var entry = ''' <tr>
|
|
<td><a href="$encodedLink">$encodedName</a></td>
|
|
<td>$encodedModified</td>
|
|
<td style="text-align: right">$encodedSize</td>
|
|
</tr>''';
|
|
response.write(entry);
|
|
}
|
|
|
|
if (path != '/') {
|
|
add('..', null, null, true);
|
|
}
|
|
|
|
dir.list(followLinks: true).listen((entity) {
|
|
var name = basename(entity.path);
|
|
var stat = entity.statSync();
|
|
if (entity is File) {
|
|
add(name, stat.modified.toString(), stat.size, false);
|
|
} else if (entity is Directory) {
|
|
add(name, stat.modified.toString(), null, true);
|
|
}
|
|
}, onError: (e) {
|
|
// TODO(kevmoo): log error
|
|
}, onDone: () {
|
|
response.write(footer);
|
|
response.close();
|
|
});
|
|
} catch (_) {
|
|
// TODO(kevmoo): log error
|
|
await response.close();
|
|
}
|
|
}
|
|
|
|
void _serveErrorPage(int error, HttpRequest request) {
|
|
var response = request.response;
|
|
response.statusCode = error;
|
|
if (_errorCallback != null) {
|
|
_errorCallback!(request);
|
|
return;
|
|
}
|
|
response.headers.contentType =
|
|
ContentType('text', 'html', parameters: {'charset': 'utf-8'});
|
|
// Default error page.
|
|
var path = Uri.decodeComponent(request.uri.path);
|
|
var encodedPath = const HtmlEscape().convert(path);
|
|
var encodedReason = const HtmlEscape().convert(response.reasonPhrase);
|
|
var encodedError = const HtmlEscape().convert(error.toString());
|
|
|
|
var server = response.headers.value(HttpHeaders.serverHeader);
|
|
server ??= '';
|
|
var page = '''<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
|
|
http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
|
|
<html xmlns="http://www.w3.org/1999/xhtml">
|
|
<head>
|
|
<title>$encodedReason: $encodedPath</title>
|
|
</head>
|
|
<body>
|
|
<h1>Error $encodedError at '$encodedPath': $encodedReason</h1>
|
|
$server
|
|
</body>
|
|
</html>''';
|
|
response.write(page);
|
|
response.close();
|
|
}
|
|
}
|
|
|
|
class _VirtualDirectoryFileStream implements StreamConsumer<List<int>> {
|
|
final HttpResponse response;
|
|
final String path;
|
|
List<int>? buffer = [];
|
|
|
|
_VirtualDirectoryFileStream(this.response, this.path);
|
|
|
|
@override
|
|
Future addStream(Stream<List<int>> stream) {
|
|
stream.listen((data) {
|
|
if (buffer == null) {
|
|
response.add(data);
|
|
return;
|
|
}
|
|
if (buffer!.isEmpty) {
|
|
if (data.length >= defaultMagicNumbersMaxLength) {
|
|
setMimeType(data);
|
|
response.add(data);
|
|
buffer = null;
|
|
} else {
|
|
buffer!.addAll(data);
|
|
}
|
|
} else {
|
|
buffer!.addAll(data);
|
|
if (buffer!.length >= defaultMagicNumbersMaxLength) {
|
|
setMimeType(buffer);
|
|
response.add(buffer!);
|
|
buffer = null;
|
|
}
|
|
}
|
|
}, onDone: () {
|
|
if (buffer != null) {
|
|
if (buffer!.isEmpty) {
|
|
setMimeType(null);
|
|
} else {
|
|
setMimeType(buffer);
|
|
response.add(buffer!);
|
|
}
|
|
}
|
|
response.close();
|
|
}, onError: response.addError);
|
|
return response.done;
|
|
}
|
|
|
|
@override
|
|
Future close() => Future.value();
|
|
|
|
void setMimeType(List<int>? bytes) {
|
|
var mimeType = lookupMimeType(path, headerBytes: bytes);
|
|
if (mimeType != null) {
|
|
response.headers.contentType = ContentType.parse(mimeType);
|
|
}
|
|
}
|
|
}
|
|
|
|
// Copied from `package:pedantic` to avoid the dep.
|
|
void _unawaited(Future<void> f) {}
|