platform/lib/src/virtual_directory.dart

335 lines
11 KiB
Dart
Raw Normal View History

import 'dart:async';
import 'package:angel_framework/angel_framework.dart';
2017-09-23 21:57:54 +00:00
import 'package:file/file.dart';
2018-08-28 14:58:28 +00:00
import 'package:http_parser/http_parser.dart';
2017-11-18 06:12:59 +00:00
import 'package:path/path.dart' as p;
2018-11-14 05:43:47 +00:00
import 'package:range_header/range_header.dart';
2017-01-25 22:40:41 +00:00
2019-05-02 23:29:09 +00:00
final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)');
String _pathify(String path) {
var p = path.replaceAll(_straySlashes, '');
Map<String, String> replace = {};
for (Match match in _param.allMatches(p)) {
if (match[3] != null) replace[match[0]] = ':${match[1]}';
}
replace.forEach((k, v) {
p = p.replaceAll(k, v);
});
return p;
}
2017-02-27 00:19:34 +00:00
/// A static server plug-in.
2017-09-23 21:57:54 +00:00
class VirtualDirectory {
2016-11-23 20:14:05 +00:00
String _prefix;
Directory _source;
2017-02-27 00:19:34 +00:00
/// The directory to serve files from.
Directory get source => _source;
2017-02-27 00:19:34 +00:00
/// An optional callback to run before serving files.
2017-09-23 21:57:54 +00:00
final Function(File file, RequestContext req, ResponseContext res) callback;
final Angel app;
final FileSystem fileSystem;
2017-02-27 00:19:34 +00:00
/// Filenames to be resolved within directories as indices.
final Iterable<String> indexFileNames;
/// An optional public path to map requests to.
final String publicPath;
2017-11-18 06:12:59 +00:00
/// If `true` (default: `false`), then if a directory does not contain any of the specific [indexFileNames], a default directory listing will be served.
final bool allowDirectoryListing;
/// If `true` (default: `true`), then files will be opened as streams and piped into the request.
///
/// If not, the response buffer will be used instead.
2018-08-28 14:58:28 +00:00
final bool useBuffer;
2017-09-23 21:57:54 +00:00
VirtualDirectory(this.app, this.fileSystem,
{Directory source,
2019-05-02 23:29:09 +00:00
this.indexFileNames = const ['index.html'],
this.publicPath = '/',
2017-11-18 06:12:59 +00:00
this.callback,
2019-05-02 23:29:09 +00:00
this.allowDirectoryListing = false,
this.useBuffer = false}) {
2016-11-23 20:14:05 +00:00
_prefix = publicPath.replaceAll(_straySlashes, '');
if (source != null) {
_source = source;
} else {
2019-05-02 23:29:09 +00:00
String dirPath = app.environment.isProduction ? './build/web' : './web';
2017-09-23 21:57:54 +00:00
_source = fileSystem.directory(dirPath);
2017-06-16 02:05:06 +00:00
}
}
2017-09-23 21:57:54 +00:00
/// Responds to incoming HTTP requests.
Future<bool> handleRequest(RequestContext req, ResponseContext res) {
2019-06-06 14:33:40 +00:00
if (req.method != 'GET' && req.method != 'HEAD') {
2019-05-02 23:29:09 +00:00
return Future<bool>.value(true);
2019-06-06 14:33:40 +00:00
}
2018-11-14 05:58:35 +00:00
var path = req.uri.path.replaceAll(_straySlashes, '');
2017-06-16 02:05:06 +00:00
2019-06-06 14:33:40 +00:00
if (_prefix?.isNotEmpty == true && !path.startsWith(_prefix)) {
2019-05-02 23:29:09 +00:00
return Future<bool>.value(true);
2019-06-06 14:33:40 +00:00
}
2017-06-16 02:05:06 +00:00
2017-09-23 21:57:54 +00:00
return servePath(path, req, res);
2017-06-16 02:05:06 +00:00
}
2017-09-23 21:57:54 +00:00
/// A handler that serves the file at the given path, unless the user has requested that path.
2017-11-18 06:12:59 +00:00
///
/// You can also limit this functionality to specific values of the `Accept` header, ex. `text/html`.
/// If [accepts] is `null`, OR at least one of the content types in [accepts] is present,
/// the view will be served.
2018-08-28 14:58:28 +00:00
RequestHandler pushState(String path, {Iterable accepts}) {
2017-09-23 21:57:54 +00:00
var vPath = path.replaceAll(_straySlashes, '');
if (_prefix?.isNotEmpty == true) vPath = '$_prefix/$vPath';
2017-06-16 02:05:06 +00:00
2017-09-23 21:57:54 +00:00
return (RequestContext req, ResponseContext res) {
var path = req.path.replaceAll(_straySlashes, '');
2019-05-02 23:29:09 +00:00
if (path == vPath) return Future<bool>.value(true);
2017-11-18 06:12:59 +00:00
if (accepts?.isNotEmpty == true) {
2019-06-06 14:33:40 +00:00
if (!accepts.any((x) => req.accepts(x, strict: true))) {
2019-05-02 23:29:09 +00:00
return Future<bool>.value(true);
2019-06-06 14:33:40 +00:00
}
2017-11-18 06:12:59 +00:00
}
2017-09-23 21:57:54 +00:00
return servePath(vPath, req, res);
};
2017-02-22 23:43:27 +00:00
}
2017-09-23 21:57:54 +00:00
/// Writes the file at the given virtual [path] to a response.
Future<bool> servePath(
String path, RequestContext req, ResponseContext res) async {
2017-02-22 23:43:27 +00:00
if (_prefix.isNotEmpty) {
2017-06-20 19:57:03 +00:00
// Only replace the *first* incidence
// Resolve: https://github.com/angel-dart/angel/issues/41
2019-05-02 23:29:09 +00:00
path = path.replaceFirst(RegExp('^' + _pathify(_prefix)), '');
2017-02-22 23:43:27 +00:00
}
if (path.isEmpty) path = '.';
2017-06-20 19:57:03 +00:00
path = path.replaceAll(_straySlashes, '');
2017-02-22 23:43:27 +00:00
var absolute = source.absolute.uri.resolve(path).toFilePath();
2018-11-13 21:25:17 +00:00
var parent = source.absolute.uri.toFilePath();
2019-06-06 14:33:40 +00:00
if (!p.isWithin(parent, absolute) && !p.equals(parent, absolute)) {
2018-11-13 21:25:17 +00:00
return true;
2019-06-06 14:33:40 +00:00
}
2018-11-13 21:25:17 +00:00
2017-09-23 21:57:54 +00:00
var stat = await fileSystem.stat(absolute);
2017-11-18 06:12:59 +00:00
return await serveStat(absolute, path, stat, req, res);
2017-02-22 23:43:27 +00:00
}
2017-09-23 21:57:54 +00:00
/// Writes the file at the path given by the [stat] to a response.
Future<bool> serveStat(String absolute, String relative, FileStat stat,
RequestContext req, ResponseContext res) async {
2019-06-06 14:33:40 +00:00
if (stat.type == FileSystemEntityType.directory) {
2017-09-23 21:57:54 +00:00
return await serveDirectory(
2017-11-18 06:12:59 +00:00
fileSystem.directory(absolute), relative, stat, req, res);
2019-06-06 14:33:40 +00:00
} else if (stat.type == FileSystemEntityType.file) {
2017-09-23 21:57:54 +00:00
return await serveFile(fileSystem.file(absolute), stat, req, res);
2019-06-06 14:33:40 +00:00
} else if (stat.type == FileSystemEntityType.link) {
2017-09-23 21:57:54 +00:00
var link = fileSystem.link(absolute);
2017-02-22 23:43:27 +00:00
return await servePath(await link.resolveSymbolicLinks(), req, res);
2019-06-06 14:33:40 +00:00
} else {
2017-02-22 23:43:27 +00:00
return true;
2019-06-06 14:33:40 +00:00
}
2017-02-22 23:43:27 +00:00
}
2016-11-23 20:14:05 +00:00
2017-09-23 21:57:54 +00:00
/// Serves the index file of a [directory], if it exists.
Future<bool> serveDirectory(Directory directory, String relative,
FileStat stat, RequestContext req, ResponseContext res) async {
2017-06-16 02:05:06 +00:00
for (String indexFileName in indexFileNames) {
final index =
2017-09-23 21:57:54 +00:00
fileSystem.file(directory.absolute.uri.resolve(indexFileName));
2017-06-16 02:05:06 +00:00
if (await index.exists()) {
return await serveFile(index, stat, req, res);
}
}
2017-11-18 06:12:59 +00:00
if (allowDirectoryListing == true) {
2019-05-02 23:29:09 +00:00
res.contentType = MediaType('text', 'html');
2017-11-18 06:12:59 +00:00
res
..write('<!DOCTYPE html>')
..write('<html>')
..write(
'<head><meta name="viewport" content="width=device-width,initial-scale=1">')
..write('<style>ul { list-style-type: none; }</style>')
2018-11-13 23:37:31 +00:00
..write('</head><body>');
2017-11-18 06:12:59 +00:00
res.write('<li><a href="..">..</a></li>');
List<FileSystemEntity> entities = await directory
.list(followLinks: false)
.toList()
2019-05-02 23:29:09 +00:00
.then((l) => List.from(l));
2017-11-18 06:12:59 +00:00
entities.sort((a, b) {
if (a is Directory) {
if (b is Directory) return a.path.compareTo(b.path);
return -1;
} else if (a is File) {
2019-06-06 14:33:40 +00:00
if (b is Directory) {
2017-11-18 06:12:59 +00:00
return 1;
2019-06-06 14:33:40 +00:00
} else if (b is File) return a.path.compareTo(b.path);
2017-11-18 06:12:59 +00:00
return -1;
} else if (b is Link) return a.path.compareTo(b.path);
return 1;
});
for (var entity in entities) {
var stub = p.basename(entity.path);
var href = stub;
String type;
2019-06-06 14:33:40 +00:00
if (entity is File) {
2017-11-18 06:12:59 +00:00
type = '[File]';
2019-06-06 14:33:40 +00:00
} else if (entity is Directory) {
2017-11-18 06:12:59 +00:00
type = '[Directory]';
2019-06-06 14:33:40 +00:00
} else if (entity is Link) type = '[Link]';
2017-11-18 06:12:59 +00:00
if (relative.isNotEmpty) href = '/' + relative + '/' + stub;
2017-11-18 06:12:59 +00:00
if (entity is Directory) href += '/';
2018-11-14 05:58:35 +00:00
href = Uri.encodeFull(href);
2017-11-18 06:12:59 +00:00
res.write('<li><a href="$href">$type $stub</a></li>');
}
res..write('</body></html>');
return false;
}
2017-06-16 02:05:06 +00:00
return true;
}
void _ensureContentTypeAllowed(String mimeType, RequestContext req) {
2017-09-23 21:57:54 +00:00
var value = req.headers.value('accept');
2017-06-16 02:05:06 +00:00
bool acceptable = value == null ||
2017-06-16 03:13:01 +00:00
value?.isNotEmpty != true ||
(mimeType?.isNotEmpty == true && value?.contains(mimeType) == true) ||
2017-06-16 03:05:07 +00:00
value?.contains('*/*') == true;
2019-06-06 14:33:40 +00:00
if (!acceptable) {
2019-05-02 23:29:09 +00:00
throw AngelHttpException(
UnsupportedError(
2017-06-16 02:05:06 +00:00
'Client requested $value, but server wanted to send $mimeType.'),
2017-09-23 21:57:54 +00:00
statusCode: 406,
2017-06-16 02:05:06 +00:00
message: '406 Not Acceptable');
2019-06-06 14:33:40 +00:00
}
2017-06-16 02:05:06 +00:00
}
2017-09-23 21:57:54 +00:00
/// Writes the contents of a file to a response.
2017-06-16 02:05:06 +00:00
Future<bool> serveFile(
File file, FileStat stat, RequestContext req, ResponseContext res) async {
2019-01-27 22:14:54 +00:00
res.headers['accept-ranges'] = 'bytes';
2017-06-16 02:05:06 +00:00
if (callback != null) {
2018-11-14 05:43:47 +00:00
return await req.app.executeHandler(
(RequestContext req, ResponseContext res) => callback(file, req, res),
req,
res);
2017-06-16 02:05:06 +00:00
}
2018-11-13 21:25:17 +00:00
var type =
app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream';
2018-11-14 05:43:47 +00:00
res.headers['accept-ranges'] = 'bytes';
2017-06-16 02:05:06 +00:00
_ensureContentTypeAllowed(type, req);
2018-11-14 05:43:47 +00:00
res.headers['accept-ranges'] = 'bytes';
2019-05-02 23:29:09 +00:00
res.contentType = MediaType.parse(type);
2018-11-13 21:25:17 +00:00
if (useBuffer == true) res.useBuffer();
2018-11-14 05:43:47 +00:00
2019-01-28 00:30:40 +00:00
if (req.headers.value('range')?.startsWith('bytes=') != true) {
2018-11-14 05:43:47 +00:00
await res.streamFile(file);
} else {
2019-05-02 23:29:09 +00:00
var header = RangeHeader.parse(req.headers.value('range'));
2018-11-14 05:43:47 +00:00
var items = RangeHeader.foldItems(header.items);
var totalFileSize = await file.length();
2019-05-02 23:29:09 +00:00
header = RangeHeader(items);
2018-11-14 05:43:47 +00:00
for (var item in header.items) {
bool invalid = false;
if (item.start != -1) {
invalid = item.end != -1 && item.end < item.start;
2019-06-06 14:33:40 +00:00
} else {
2018-11-14 05:43:47 +00:00
invalid = item.end == -1;
2019-06-06 14:33:40 +00:00
}
2018-11-14 05:43:47 +00:00
if (invalid) {
2019-05-02 23:29:09 +00:00
throw AngelHttpException(
Exception("Semantically invalid, or unbounded range."),
2018-11-14 05:43:47 +00:00
statusCode: 416,
message: "Semantically invalid, or unbounded range.");
}
// Ensure it's within range.
if (item.start >= totalFileSize || item.end >= totalFileSize) {
2019-05-02 23:29:09 +00:00
throw AngelHttpException(
Exception("Given range $item is out of bounds."),
2018-11-14 05:43:47 +00:00
statusCode: 416,
message: "Given range $item is out of bounds.");
}
}
if (header.items.isEmpty) {
2019-05-02 23:29:09 +00:00
throw AngelHttpException(null,
2018-11-14 05:43:47 +00:00
statusCode: 416, message: '`Range` header may not be empty.');
} else if (header.items.length == 1) {
var item = header.items[0];
Stream<List<int>> stream;
int len = 0, total = totalFileSize;
if (item.start == -1) {
if (item.end == -1) {
len = total;
stream = file.openRead();
} else {
len = item.end + 1;
stream = file.openRead(0, item.end + 1);
}
} else {
if (item.end == -1) {
len = total - item.start;
stream = file.openRead(item.start);
} else {
len = item.end - item.start + 1;
stream = file.openRead(item.start, item.end + 1);
}
}
2019-05-02 23:29:09 +00:00
res.contentType = MediaType.parse(
2018-11-14 05:43:47 +00:00
app.mimeTypeResolver.lookup(file.path) ??
'application/octet-stream');
res.statusCode = 206;
res.headers['content-length'] = len.toString();
res.headers['content-range'] = 'bytes ' + item.toContentRange(total);
await stream.pipe(res);
return false;
} else {
2019-05-02 23:29:09 +00:00
var transformer = RangeHeaderTransformer(
2018-11-14 05:43:47 +00:00
header,
app.mimeTypeResolver.lookup(file.path) ??
'application/octet-stream',
await file.length());
res.statusCode = 206;
res.headers['content-length'] =
transformer.computeContentLength(totalFileSize).toString();
2019-05-02 23:29:09 +00:00
res.contentType = MediaType(
2018-11-14 05:43:47 +00:00
'multipart', 'byteranges', {'boundary': transformer.boundary});
await file.openRead().transform(transformer).pipe(res);
return false;
}
}
2017-06-16 02:05:06 +00:00
return false;
}
}