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'));
+}