// Copyright (c) 2015, 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:io'; import 'dart:math' as math; import 'package:convert/convert.dart'; import 'package:http_parser/http_parser.dart'; import 'package:mime/mime.dart'; import 'package:path/path.dart' as p; import 'package:shelf/shelf.dart'; import 'directory_listing.dart'; import 'util.dart'; /// The default resolver for MIME types based on file extensions. final _defaultMimeTypeResolver = MimeTypeResolver(); // TODO option to exclude hidden files? /// Creates a Shelf [Handler] that serves files from the provided /// [fileSystemPath]. /// /// Accessing a path containing symbolic links will succeed only if the resolved /// path is within [fileSystemPath]. To allow access to paths outside of /// [fileSystemPath], set [serveFilesOutsidePath] to `true`. /// /// When a existing directory is requested and a [defaultDocument] is specified /// the directory is checked for a file with that name. If it exists, it is /// served. /// /// If no [defaultDocument] is found and [listDirectories] is true, then the /// handler produces a listing of the directory. /// /// If [useHeaderBytesForContentType] is `true`, the contents of the /// file will be used along with the file path to determine the content type. /// /// Specify a custom [contentTypeResolver] to customize automatic content type /// detection. Handler createStaticHandler(String fileSystemPath, {bool serveFilesOutsidePath = false, String? defaultDocument, bool listDirectories = false, bool useHeaderBytesForContentType = false, MimeTypeResolver? contentTypeResolver}) { final rootDir = Directory(fileSystemPath); if (!rootDir.existsSync()) { throw ArgumentError('A directory corresponding to fileSystemPath ' '"$fileSystemPath" could not be found'); } fileSystemPath = rootDir.resolveSymbolicLinksSync(); if (defaultDocument != null) { if (defaultDocument != p.basename(defaultDocument)) { throw ArgumentError('defaultDocument must be a file name.'); } } final mimeResolver = contentTypeResolver ?? _defaultMimeTypeResolver; return (Request request) { final segs = [fileSystemPath, ...request.url.pathSegments]; final fsPath = p.joinAll(segs); final entityType = FileSystemEntity.typeSync(fsPath); File? fileFound; if (entityType == FileSystemEntityType.file) { fileFound = File(fsPath); } else if (entityType == FileSystemEntityType.directory) { fileFound = _tryDefaultFile(fsPath, defaultDocument); if (fileFound == null && listDirectories) { final uri = request.requestedUri; if (!uri.path.endsWith('/')) return _redirectToAddTrailingSlash(uri); return listDirectory(fileSystemPath, fsPath); } } if (fileFound == null) { return Response.notFound('Not Found'); } final file = fileFound; if (!serveFilesOutsidePath) { final resolvedPath = file.resolveSymbolicLinksSync(); // Do not serve a file outside of the original fileSystemPath if (!p.isWithin(fileSystemPath, resolvedPath)) { return Response.notFound('Not Found'); } } // when serving the default document for a directory, if the requested // path doesn't end with '/', redirect to the path with a trailing '/' final uri = request.requestedUri; if (entityType == FileSystemEntityType.directory && !uri.path.endsWith('/')) { return _redirectToAddTrailingSlash(uri); } return _handleFile(request, file, () async { if (useHeaderBytesForContentType) { final length = math.min(mimeResolver.magicNumbersMaxLength, file.lengthSync()); final byteSink = ByteAccumulatorSink(); await file.openRead(0, length).listen(byteSink.add).asFuture(); return mimeResolver.lookup(file.path, headerBytes: byteSink.bytes); } else { return mimeResolver.lookup(file.path); } }); }; } Response _redirectToAddTrailingSlash(Uri uri) { final location = Uri( scheme: uri.scheme, userInfo: uri.userInfo, host: uri.host, port: uri.port, path: '${uri.path}/', query: uri.query); return Response.movedPermanently(location.toString()); } File? _tryDefaultFile(String dirPath, String? defaultFile) { if (defaultFile == null) return null; final filePath = p.join(dirPath, defaultFile); final file = File(filePath); if (file.existsSync()) { return file; } return null; } /// Creates a shelf [Handler] that serves the file at [path]. /// /// This returns a 404 response for any requests whose [Request.url] doesn't /// match [url]. The [url] defaults to the basename of [path]. /// /// This uses the given [contentType] for the Content-Type header. It defaults /// to looking up a content type based on [path]'s file extension, and failing /// that doesn't sent a [contentType] header at all. Handler createFileHandler(String path, {String? url, String? contentType}) { final file = File(path); if (!file.existsSync()) { throw ArgumentError.value(path, 'path', 'does not exist.'); } else if (url != null && !p.url.isRelative(url)) { throw ArgumentError.value(url, 'url', 'must be relative.'); } final mimeType = contentType ?? _defaultMimeTypeResolver.lookup(path); url ??= p.toUri(p.basename(path)).toString(); return (request) { if (request.url.path != url) return Response.notFound('Not Found'); return _handleFile(request, file, () => mimeType); }; } /// Serves the contents of [file] in response to [request]. /// /// This handles caching, and sends a 304 Not Modified response if the request /// indicates that it has the latest version of a file. Otherwise, it calls /// [getContentType] and uses it to populate the Content-Type header. Future _handleFile(Request request, File file, FutureOr Function() getContentType) async { final stat = file.statSync(); final ifModifiedSince = request.ifModifiedSince; if (ifModifiedSince != null) { final fileChangeAtSecResolution = toSecondResolution(stat.modified); if (!fileChangeAtSecResolution.isAfter(ifModifiedSince)) { return Response.notModified(); } } final contentType = await getContentType(); final headers = { HttpHeaders.lastModifiedHeader: formatHttpDate(stat.modified), HttpHeaders.acceptRangesHeader: 'bytes', if (contentType != null) HttpHeaders.contentTypeHeader: contentType, }; return _fileRangeResponse(request, file, headers) ?? Response.ok( request.method == 'HEAD' ? null : file.openRead(), headers: headers..[HttpHeaders.contentLengthHeader] = '${stat.size}', ); } final _bytesMatcher = RegExp(r'^bytes=(\d*)-(\d*)$'); /// Serves a range of [file], if [request] is valid 'bytes' range request. /// /// If the request does not specify a range, specifies a range of the wrong /// type, or has a syntactic error the range is ignored and `null` is returned. /// /// If the range request is valid but the file is not long enough to include the /// start of the range a range not satisfiable response is returned. /// /// Ranges that end past the end of the file are truncated. Response? _fileRangeResponse( Request request, File file, Map headers) { final range = request.headers[HttpHeaders.rangeHeader]; if (range == null) return null; final matches = _bytesMatcher.firstMatch(range); // Ignore ranges other than bytes if (matches == null) return null; final actualLength = file.lengthSync(); final startMatch = matches[1]!; final endMatch = matches[2]!; if (startMatch.isEmpty && endMatch.isEmpty) return null; int start; // First byte position - inclusive. int end; // Last byte position - inclusive. if (startMatch.isEmpty) { start = actualLength - int.parse(endMatch); if (start < 0) start = 0; end = actualLength - 1; } else { start = int.parse(startMatch); end = endMatch.isEmpty ? actualLength - 1 : int.parse(endMatch); } // If the range is syntactically invalid the Range header // MUST be ignored (RFC 2616 section 14.35.1). if (start > end) return null; if (end >= actualLength) { end = actualLength - 1; } if (start >= actualLength) { return Response( HttpStatus.requestedRangeNotSatisfiable, headers: headers, ); } return Response( HttpStatus.partialContent, body: request.method == 'HEAD' ? null : file.openRead(start, end + 1), headers: headers ..[HttpHeaders.contentLengthHeader] = (end - start + 1).toString() ..[HttpHeaders.contentRangeHeader] = 'bytes $start-$end/$actualLength', ); }