diff --git a/pkgs/shelf_static/CHANGELOG.md b/pkgs/shelf_static/CHANGELOG.md index 16e99067..b27e300a 100644 --- a/pkgs/shelf_static/CHANGELOG.md +++ b/pkgs/shelf_static/CHANGELOG.md @@ -1,5 +1,9 @@ ## 1.1.3 +* `Response`: + * Populate `context` with the file system paths used for resolution. + * Possible entries: `shelf_static:file`, `shelf_static:file_not_found`, and `shelf_static:directory`. + * Require Dart `^3.3.0`. * Update `package:mime` constraint to `>=1.0.0 <3.0.0`. diff --git a/pkgs/shelf_static/lib/src/directory_listing.dart b/pkgs/shelf_static/lib/src/directory_listing.dart index a9d7bfc6..47fca03d 100644 --- a/pkgs/shelf_static/lib/src/directory_listing.dart +++ b/pkgs/shelf_static/lib/src/directory_listing.dart @@ -9,6 +9,8 @@ import 'dart:io'; import 'package:path/path.dart' as path; import 'package:shelf/shelf.dart'; +import 'util.dart' show buildResponseContext; + String _getHeader(String sanitizedHeading) => ''' @@ -68,8 +70,10 @@ Response listDirectory(String fileSystemPath, String dirPath) { add(_getHeader(sanitizer.convert(heading))); + var dir = Directory(dirPath); + // Return a sorted listing of the directory contents asynchronously. - Directory(dirPath).list().toList().then((entities) { + dir.list().toList().then((entities) { entities.sort((e1, e2) { if (e1 is Directory && e2 is! Directory) { return -1; @@ -95,5 +99,6 @@ Response listDirectory(String fileSystemPath, String dirPath) { controller.stream, encoding: encoding, headers: {HttpHeaders.contentTypeHeader: 'text/html'}, + context: buildResponseContext(directory: dir), ); } diff --git a/pkgs/shelf_static/lib/src/static_handler.dart b/pkgs/shelf_static/lib/src/static_handler.dart index 92b50093..3b4e2c64 100644 --- a/pkgs/shelf_static/lib/src/static_handler.dart +++ b/pkgs/shelf_static/lib/src/static_handler.dart @@ -39,6 +39,13 @@ final _defaultMimeTypeResolver = MimeTypeResolver(); /// /// Specify a custom [contentTypeResolver] to customize automatic content type /// detection. +/// +/// The [Response.context] will be populated with "shelf_static:file" or +/// "shelf_static:file_not_found" with the resolved [File] for the [Response]. +/// If the path resolves to a [Directory], it will populate +/// "shelf_static:directory". If the path is considered not found because it is +/// outside of the [fileSystemPath] and [serveFilesOutsidePath] is false, +/// then none of the keys will be included in the context. Handler createStaticHandler(String fileSystemPath, {bool serveFilesOutsidePath = false, String? defaultDocument, @@ -76,14 +83,29 @@ Handler createStaticHandler(String fileSystemPath, fileFound = _tryDefaultFile(fsPath, defaultDocument); if (fileFound == null && listDirectories) { final uri = request.requestedUri; - if (!uri.path.endsWith('/')) return _redirectToAddTrailingSlash(uri); + if (!uri.path.endsWith('/')) { + return _redirectToAddTrailingSlash(uri, fsPath); + } return listDirectory(fileSystemPath, fsPath); } } if (fileFound == null) { - return Response.notFound('Not Found'); + File? fileNotFound = File(fsPath); + + // Do not expose a file path outside of the original fileSystemPath: + if (!serveFilesOutsidePath && + !p.isWithin(fileSystemPath, fileNotFound.path) && + !p.equals(fileSystemPath, fileNotFound.path)) { + fileNotFound = null; + } + + return Response.notFound( + 'Not Found', + context: buildResponseContext(fileNotFound: fileNotFound), + ); } + final file = fileFound; if (!serveFilesOutsidePath) { @@ -100,7 +122,7 @@ Handler createStaticHandler(String fileSystemPath, final uri = request.requestedUri; if (entityType == FileSystemEntityType.directory && !uri.path.endsWith('/')) { - return _redirectToAddTrailingSlash(uri); + return _redirectToAddTrailingSlash(uri, fsPath); } return _handleFile(request, file, () async { @@ -120,7 +142,7 @@ Handler createStaticHandler(String fileSystemPath, }; } -Response _redirectToAddTrailingSlash(Uri uri) { +Response _redirectToAddTrailingSlash(Uri uri, String fsPath) { final location = Uri( scheme: uri.scheme, userInfo: uri.userInfo, @@ -129,7 +151,8 @@ Response _redirectToAddTrailingSlash(Uri uri) { path: '${uri.path}/', query: uri.query); - return Response.movedPermanently(location.toString()); + return Response.movedPermanently(location.toString(), + context: buildResponseContext(directory: Directory(fsPath))); } File? _tryDefaultFile(String dirPath, String? defaultFile) { @@ -154,6 +177,12 @@ File? _tryDefaultFile(String dirPath, String? defaultFile) { /// 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. +/// +/// The [Response.context] will be populated with "shelf_static:file" or +/// "shelf_static:file_not_found" with the resolved [File] for the [Response]. +/// If the path is considered not found because it is +/// outside of the [fileSystemPath] and [serveFilesOutsidePath] is false, +/// then neither key will be included in the context. Handler createFileHandler(String path, {String? url, String? contentType}) { final file = File(path); if (!file.existsSync()) { @@ -162,11 +191,20 @@ Handler createFileHandler(String path, {String? url, String? contentType}) { throw ArgumentError.value(url, 'url', 'must be relative.'); } + final parent = file.parent; + 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'); + if (request.url.path != url) { + var fileNotFound = + File(p.joinAll([parent.path, ...request.url.pathSegments])); + return Response.notFound( + 'Not Found', + context: buildResponseContext(fileNotFound: fileNotFound), + ); + } return _handleFile(request, file, () => mimeType); }; } @@ -184,7 +222,9 @@ Future _handleFile(Request request, File file, if (ifModifiedSince != null) { final fileChangeAtSecResolution = toSecondResolution(stat.modified); if (!fileChangeAtSecResolution.isAfter(ifModifiedSince)) { - return Response.notModified(); + return Response.notModified( + context: buildResponseContext(file: file), + ); } } @@ -199,6 +239,7 @@ Future _handleFile(Request request, File file, Response.ok( request.method == 'HEAD' ? null : file.openRead(), headers: headers..[HttpHeaders.contentLengthHeader] = '${stat.size}', + context: buildResponseContext(file: file), ); } @@ -248,6 +289,7 @@ Response? _fileRangeResponse( return Response( HttpStatus.requestedRangeNotSatisfiable, headers: headers, + context: buildResponseContext(file: file), ); } return Response( @@ -256,5 +298,6 @@ Response? _fileRangeResponse( headers: headers ..[HttpHeaders.contentLengthHeader] = (end - start + 1).toString() ..[HttpHeaders.contentRangeHeader] = 'bytes $start-$end/$actualLength', + context: buildResponseContext(file: file), ); } diff --git a/pkgs/shelf_static/lib/src/util.dart b/pkgs/shelf_static/lib/src/util.dart index 2d423799..de9219ec 100644 --- a/pkgs/shelf_static/lib/src/util.dart +++ b/pkgs/shelf_static/lib/src/util.dart @@ -2,7 +2,25 @@ // 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:io'; + DateTime toSecondResolution(DateTime dt) { if (dt.millisecond == 0) return dt; return dt.subtract(Duration(milliseconds: dt.millisecond)); } + +Map? buildResponseContext( + {File? file, File? fileNotFound, Directory? directory}) { + // Ensure other shelf `Middleware` can identify + // the processed file/directory in the `Response` by including + // `file`, `file_not_found` and `directory` in the context: + if (file != null) { + return {'shelf_static:file': file}; + } else if (fileNotFound != null) { + return {'shelf_static:file_not_found': fileNotFound}; + } else if (directory != null) { + return {'shelf_static:directory': directory}; + } else { + return null; + } +} diff --git a/pkgs/shelf_static/test/alternative_root_test.dart b/pkgs/shelf_static/test/alternative_root_test.dart index 3a18e96a..0d57d389 100644 --- a/pkgs/shelf_static/test/alternative_root_test.dart +++ b/pkgs/shelf_static/test/alternative_root_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:path/path.dart' as p; import 'package:shelf_static/shelf_static.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; @@ -27,6 +28,11 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 8); expect(response.readAsString(), completion('root txt')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'root.txt')}), + ); }); test('access root file with space', () async { diff --git a/pkgs/shelf_static/test/basic_file_test.dart b/pkgs/shelf_static/test/basic_file_test.dart index a1425daf..740c5f98 100644 --- a/pkgs/shelf_static/test/basic_file_test.dart +++ b/pkgs/shelf_static/test/basic_file_test.dart @@ -46,6 +46,11 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 8); expect(response.readAsString(), completion('root txt')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'root.txt')}), + ); }); test('HEAD', () async { @@ -55,6 +60,11 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 8); expect(await response.readAsString(), isEmpty); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'root.txt')}), + ); }); test('access root file with space', () async { @@ -64,6 +74,12 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 18); expect(response.readAsString(), completion('with space content')); + + expect( + response.context.toFilePath(), + equals( + {'shelf_static:file': p.join(d.sandbox, 'files', 'with space.txt')}), + ); }); test('access root file with unencoded space', () async { @@ -89,6 +105,12 @@ void main() { final response = await makeRequest(handler, '/not_here.txt'); expect(response.statusCode, HttpStatus.notFound); + + expect( + response.context.toFilePath(), + equals( + {'shelf_static:file_not_found': p.join(d.sandbox, 'not_here.txt')}), + ); }); test('last modified', () async { @@ -99,6 +121,11 @@ void main() { final response = await makeRequest(handler, '/root.txt'); expect(response.lastModified, atSameTimeToSecond(modified)); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'root.txt')}), + ); }); group('if modified since', () { @@ -116,6 +143,11 @@ void main() { await makeRequest(handler, '/root.txt', headers: headers); expect(response.statusCode, HttpStatus.notModified); expect(response.contentLength, 0); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'root.txt')}), + ); }); test('before last modified', () async { diff --git a/pkgs/shelf_static/test/create_file_handler_test.dart b/pkgs/shelf_static/test/create_file_handler_test.dart index 91c4c865..faefd3d0 100644 --- a/pkgs/shelf_static/test/create_file_handler_test.dart +++ b/pkgs/shelf_static/test/create_file_handler_test.dart @@ -23,12 +23,24 @@ void main() { expect(response.statusCode, equals(HttpStatus.ok)); expect(response.contentLength, equals(8)); expect(response.readAsString(), completion(equals('contents'))); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test('serves a 404 for a non-matching URL', () async { final handler = createFileHandler(p.join(d.sandbox, 'file.txt')); final response = await makeRequest(handler, '/foo/file.txt'); expect(response.statusCode, equals(HttpStatus.notFound)); + + expect( + response.context.toFilePath(), + equals({ + 'shelf_static:file_not_found': p.join(d.sandbox, 'foo', 'file.txt') + }), + ); }); test('serves the file contents under a custom URL', () async { @@ -38,6 +50,11 @@ void main() { expect(response.statusCode, equals(HttpStatus.ok)); expect(response.contentLength, equals(8)); expect(response.readAsString(), completion(equals('contents'))); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test("serves a 404 if the custom URL isn't matched", () async { @@ -45,6 +62,11 @@ void main() { createFileHandler(p.join(d.sandbox, 'file.txt'), url: 'foo/bar'); final response = await makeRequest(handler, '/file.txt'); expect(response.statusCode, equals(HttpStatus.notFound)); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file_not_found': p.join(d.sandbox, 'file.txt')}), + ); }); group('the content type header', () { @@ -53,6 +75,11 @@ void main() { final response = await makeRequest(handler, '/file.txt'); expect(response.statusCode, equals(HttpStatus.ok)); expect(response.mimeType, equals('text/plain')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test("is omitted if it can't be inferred", () async { @@ -89,6 +116,11 @@ void main() { containsPair(HttpHeaders.contentRangeHeader, 'bytes 0-4/8'), ); expect(response.headers, containsPair('content-length', '5')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test('at the end of has overflow from 0 to 9', () async { @@ -111,6 +143,11 @@ void main() { containsPair(HttpHeaders.contentRangeHeader, 'bytes 0-7/8'), ); expect(response.headers, containsPair('content-length', '8')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test('at the start of has overflow from 8 to 9', () async { @@ -129,6 +166,11 @@ void main() { response.statusCode, HttpStatus.requestedRangeNotSatisfiable, ); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test('ignores invalid request with start > end', () async { @@ -141,6 +183,11 @@ void main() { expect(response.statusCode, equals(HttpStatus.ok)); expect(response.contentLength, equals(8)); expect(response.readAsString(), completion(equals('contents'))); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'file.txt')}), + ); }); test('ignores request with start > end', () async { diff --git a/pkgs/shelf_static/test/default_document_test.dart b/pkgs/shelf_static/test/default_document_test.dart index c6b5fb11..08ea17d5 100644 --- a/pkgs/shelf_static/test/default_document_test.dart +++ b/pkgs/shelf_static/test/default_document_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:path/path.dart' as p; import 'package:shelf_static/shelf_static.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; @@ -45,6 +46,11 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 13); expect(response.readAsString(), completion('')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'index.html')}), + ); }); test('access "/"', () async { @@ -52,6 +58,11 @@ void main() { final response = await makeRequest(handler, '/'); expect(response.statusCode, HttpStatus.notFound); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file_not_found': d.sandbox}), + ); }); test('access "/files"', () async { @@ -59,6 +70,11 @@ void main() { final response = await makeRequest(handler, '/files'); expect(response.statusCode, HttpStatus.notFound); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file_not_found': p.join(d.sandbox, 'files')}), + ); }); test('access "/files/" dir', () async { @@ -66,6 +82,11 @@ void main() { final response = await makeRequest(handler, '/files/'); expect(response.statusCode, HttpStatus.notFound); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file_not_found': p.join(d.sandbox, 'files')}), + ); }); }); @@ -79,6 +100,11 @@ void main() { expect(response.contentLength, 13); expect(response.readAsString(), completion('')); expect(response.mimeType, 'text/html'); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'index.html')}), + ); }); test('access "/"', () async { @@ -90,6 +116,11 @@ void main() { expect(response.contentLength, 13); expect(response.readAsString(), completion('')); expect(response.mimeType, 'text/html'); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'index.html')}), + ); }); test('access "/files"', () async { @@ -100,6 +131,11 @@ void main() { expect(response.statusCode, HttpStatus.movedPermanently); expect(response.headers, containsPair(HttpHeaders.locationHeader, 'http://localhost/files/')); + + expect( + response.context.toDirectoryPath(), + equals({'shelf_static:directory': p.join(d.sandbox, 'files')}), + ); }); test('access "/files/" dir', () async { @@ -112,6 +148,11 @@ void main() { expect(response.readAsString(), completion('files')); expect(response.mimeType, 'text/html'); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'files', 'index.html')}), + ); }); }); } diff --git a/pkgs/shelf_static/test/directory_listing_test.dart b/pkgs/shelf_static/test/directory_listing_test.dart index f587af13..643f0efe 100644 --- a/pkgs/shelf_static/test/directory_listing_test.dart +++ b/pkgs/shelf_static/test/directory_listing_test.dart @@ -4,6 +4,7 @@ import 'dart:io'; +import 'package:path/path.dart' as p; import 'package:shelf_static/shelf_static.dart'; import 'package:test/test.dart'; import 'package:test_descriptor/test_descriptor.dart' as d; @@ -27,6 +28,11 @@ void main() { final response = await makeRequest(handler, '/'); expect(response.statusCode, HttpStatus.ok); expect(response.readAsString(), completes); + + expect( + response.context.toDirectoryPath(), + equals({'shelf_static:directory': d.sandbox}), + ); }); test('access "/files"', () async { @@ -36,6 +42,11 @@ void main() { expect(response.statusCode, HttpStatus.movedPermanently); expect(response.headers, containsPair(HttpHeaders.locationHeader, 'http://localhost/files/')); + + expect( + response.context.toDirectoryPath(), + equals({'shelf_static:directory': p.join(d.sandbox, 'files')}), + ); }); test('access "/files/"', () async { diff --git a/pkgs/shelf_static/test/symbolic_link_test.dart b/pkgs/shelf_static/test/symbolic_link_test.dart index 80ba32c5..dc3b392f 100644 --- a/pkgs/shelf_static/test/symbolic_link_test.dart +++ b/pkgs/shelf_static/test/symbolic_link_test.dart @@ -44,6 +44,13 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 13); expect(response.readAsString(), completion('')); + + expect( + response.context.toFilePath(), + equals({ + 'shelf_static:file': p.join(d.sandbox, 'originals', 'index.html') + }), + ); }); group('links under root dir', () { @@ -56,6 +63,11 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 13); expect(response.readAsString(), completion('')); + + expect( + response.context.toFilePath(), + equals({'shelf_static:file': p.join(d.sandbox, 'link_index.html')}), + ); }, onPlatform: _skipSymlinksOnWindows, ); @@ -67,6 +79,13 @@ void main() { expect(response.statusCode, HttpStatus.ok); expect(response.contentLength, 13); expect(response.readAsString(), completion('')); + + expect( + response.context.toFilePath(), + equals({ + 'shelf_static:file': p.join(d.sandbox, 'link_dir', 'index.html') + }), + ); }); }); @@ -76,6 +95,11 @@ void main() { final response = await makeRequest(handler, '/link_index.html'); expect(response.statusCode, HttpStatus.notFound); + + expect( + response.context.toFilePath(), + equals({}), // outside of the root path: empty context. + ); }); test('access file in sym linked dir', () async { @@ -83,6 +107,11 @@ void main() { final response = await makeRequest(handler, '/link_dir/index.html'); expect(response.statusCode, HttpStatus.notFound); + + expect( + response.context.toFilePath(), + equals({}), // outside of the root path: empty context. + ); }); }); }); diff --git a/pkgs/shelf_static/test/test_util.dart b/pkgs/shelf_static/test/test_util.dart index 81d5ead9..879f6312 100644 --- a/pkgs/shelf_static/test/test_util.dart +++ b/pkgs/shelf_static/test/test_util.dart @@ -2,6 +2,8 @@ // 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:io'; + import 'package:path/path.dart' as p; import 'package:shelf/shelf.dart'; import 'package:shelf_static/src/util.dart'; @@ -69,3 +71,11 @@ class _SecondResolutionDateTimeMatcher extends Matcher { bool _datesEqualToSecond(DateTime d1, DateTime d2) => toSecondResolution(d1).isAtSameMomentAs(toSecondResolution(d2)); + +extension ResponseContextExtension on Map { + Map toFilePath() => + map((k, v) => MapEntry(k, v is File ? v.path : '$v')); + + Map toDirectoryPath() => + map((k, v) => MapEntry(k, v is Directory ? v.path : '$v')); +}