platform/common/http_server/lib/src/virtual_directory.dart

462 lines
15 KiB
Dart
Raw Normal View History

// 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) {}