Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions pkgs/shelf_static/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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`.

Expand Down
7 changes: 6 additions & 1 deletion pkgs/shelf_static/lib/src/directory_listing.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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) => '''<!DOCTYPE html>
<html>
<head>
Expand Down Expand Up @@ -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;
Expand All @@ -95,5 +99,6 @@ Response listDirectory(String fileSystemPath, String dirPath) {
controller.stream,
encoding: encoding,
headers: {HttpHeaders.contentTypeHeader: 'text/html'},
context: buildResponseContext(directory: dir),
);
}
57 changes: 50 additions & 7 deletions pkgs/shelf_static/lib/src/static_handler.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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) {
Expand All @@ -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 {
Expand All @@ -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,
Expand All @@ -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) {
Expand All @@ -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()) {
Expand All @@ -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);
};
}
Expand All @@ -184,7 +222,9 @@ Future<Response> _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),
);
}
}

Expand All @@ -199,6 +239,7 @@ Future<Response> _handleFile(Request request, File file,
Response.ok(
request.method == 'HEAD' ? null : file.openRead(),
headers: headers..[HttpHeaders.contentLengthHeader] = '${stat.size}',
context: buildResponseContext(file: file),
);
}

Expand Down Expand Up @@ -248,6 +289,7 @@ Response? _fileRangeResponse(
return Response(
HttpStatus.requestedRangeNotSatisfiable,
headers: headers,
context: buildResponseContext(file: file),
);
}
return Response(
Expand All @@ -256,5 +298,6 @@ Response? _fileRangeResponse(
headers: headers
..[HttpHeaders.contentLengthHeader] = (end - start + 1).toString()
..[HttpHeaders.contentRangeHeader] = 'bytes $start-$end/$actualLength',
context: buildResponseContext(file: file),
);
}
18 changes: 18 additions & 0 deletions pkgs/shelf_static/lib/src/util.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, Object>? 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;
}
}
6 changes: 6 additions & 0 deletions pkgs/shelf_static/test/alternative_root_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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 {
Expand Down
32 changes: 32 additions & 0 deletions pkgs/shelf_static/test/basic_file_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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 {
Expand All @@ -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', () {
Expand All @@ -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 {
Expand Down
Loading