// 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 _pathPrefixSegments; final RegExp _invalidPathRegExp = RegExp('[\\/\x00]'); void Function(HttpRequest)? _errorCallback; void Function(Directory, HttpRequest)? _dirCallback; static List _parsePathPrefix(String? pathPrefix) { if (pathPrefix == null) return []; 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 serve(Stream 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 _locateResource( String path, HasCurrentIterator 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>() .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>() .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 = ''' Index of $encodedPath

Index of $encodedPath

'''; var server = response.headers.value(HttpHeaders.serverHeader); server ??= ''; var footer = '''
Name Last modified Size
$server '''; 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 = ''' $encodedName $encodedModified $encodedSize '''; 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 = ''' $encodedReason: $encodedPath

Error $encodedError at '$encodedPath': $encodedReason

$server '''; response.write(page); response.close(); } } class _VirtualDirectoryFileStream implements StreamConsumer> { final HttpResponse response; final String path; List? buffer = []; _VirtualDirectoryFileStream(this.response, this.path); @override Future addStream(Stream> 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? 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 f) {}