import 'dart:async'; import 'package:angel3_framework/angel3_framework.dart'; import 'package:file/file.dart'; import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart' as p; import 'package:logging/logging.dart'; import 'package:belatuk_range_header/belatuk_range_header.dart'; final RegExp _param = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); String _pathify(String path) { var p = path.replaceAll(_straySlashes, ''); var replace = {}; for (Match match in _param.allMatches(p)) { if (match[3] != null) replace[match[0]] = ':${match[1]}'; } replace.forEach((k, v) { if (k is String && v is String) { p = p.replaceAll(k, v); } }); return p; } /// A static server plug-in. class VirtualDirectory { final _log = Logger('VirtualDirectory'); late String _prefix; late Directory _source; /// The directory to serve files from. Directory get source => _source; /// An optional callback to run before serving files. final Function(File file, RequestContext req, ResponseContext res)? callback; final Angel app; final FileSystem fileSystem; /// Filenames to be resolved within directories as indices. final Iterable indexFileNames; /// An optional public path to map requests to. final String publicPath; /// 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. final bool useBuffer; VirtualDirectory(this.app, this.fileSystem, {Directory? source, this.indexFileNames = const ['index.html'], this.publicPath = '/', this.callback, this.allowDirectoryListing = false, this.useBuffer = false}) { _prefix = publicPath.replaceAll(_straySlashes, ''); if (source != null) { _source = source; } else { var dirPath = app.environment.isProduction ? './build/web' : './web'; _source = fileSystem.directory(dirPath); } } /// Responds to incoming HTTP requests. Future handleRequest(RequestContext req, ResponseContext res) { if (req.method != 'GET' && req.method != 'HEAD') { return Future.value(true); } var path = req.uri!.path.replaceAll(_straySlashes, ''); if (_prefix.isNotEmpty == true && !path.startsWith(_prefix)) { return Future.value(true); } return servePath(path, req, res); } /// A handler that serves the file at the given path, unless the user has requested that path. /// /// 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. RequestHandler pushState(String path, {Iterable? accepts}) { var vPath = path.replaceAll(_straySlashes, ''); if (_prefix.isNotEmpty == true) vPath = '$_prefix/$vPath'; return (RequestContext req, ResponseContext res) { var path = req.path.replaceAll(_straySlashes, ''); if (path == vPath) return Future.value(true); if (accepts?.isNotEmpty == true) { if (!accepts!.any((x) => req.accepts(x, strict: true))) { return Future.value(true); } } return servePath(vPath, req, res); }; } /// Writes the file at the given virtual [path] to a response. Future servePath( String path, RequestContext req, ResponseContext res) async { if (_prefix.isNotEmpty) { // Only replace the *first* incidence // Resolve: https://github.com/angel-dart/angel/issues/41 path = path.replaceFirst(RegExp('^${_pathify(_prefix)}'), ''); } if (path.isEmpty) path = '.'; path = path.replaceAll(_straySlashes, ''); var absolute = source.absolute.uri.resolve(path).toFilePath(); var parent = source.absolute.uri.toFilePath(); if (!p.isWithin(parent, absolute) && !p.equals(parent, absolute)) { return true; } // Update to the correct file separator based on file system if (absolute.contains('\\') && fileSystem.path.separator == '/') { absolute = absolute.replaceAll('\\', '/'); _log.warning( 'Incompatible file system type is used. Changed file separator from "\\" to "/".'); } else if (absolute.contains('/') && fileSystem.path.separator == '\\') { absolute = absolute.replaceAll('/', '\\'); _log.warning( 'Incompatible file system type. Changed file separator from "/" to "\\".'); } var stat = await fileSystem.stat(absolute); return await serveStat(absolute, path, stat, req, res); } /// Writes the file at the path given by the [stat] to a response. Future serveStat(String absolute, String relative, FileStat stat, RequestContext req, ResponseContext res) async { if (stat.type == FileSystemEntityType.directory) { return await serveDirectory( fileSystem.directory(absolute), relative, stat, req, res); } else if (stat.type == FileSystemEntityType.file) { return await serveFile(fileSystem.file(absolute), stat, req, res); } else if (stat.type == FileSystemEntityType.link) { var link = fileSystem.link(absolute); return await servePath(await link.resolveSymbolicLinks(), req, res); } else { return true; } } /// Serves the index file of a [directory], if it exists. Future serveDirectory(Directory directory, String relative, FileStat stat, RequestContext req, ResponseContext res) async { for (var indexFileName in indexFileNames) { final index = fileSystem.file(directory.absolute.uri.resolve(indexFileName)); if (await index.exists()) { return await serveFile(index, stat, req, res); } } if (allowDirectoryListing == true) { res.contentType = MediaType('text', 'html'); res ..write('') ..write('') ..write( '') ..write('') ..write(''); res.write('
  • ..
  • '); var entities = await directory .list(followLinks: false) .toList() .then((l) => List.from(l)); 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) { if (b is Directory) { return 1; } else if (b is File) { return a.path.compareTo(b.path); } return -1; } else if (a is Link) { if (b is Directory) { return 1; } else if (b is Link) { return a.path.compareTo(b.path); } return -1; } return 1; }); for (var entity in entities) { String stub; String type; if (entity is File) { type = '[File]'; stub = p.basename(entity.path); } else if (entity is Directory) { type = '[Directory]'; stub = p.basename(entity.path); } else if (entity is Link) { type = '[Link]'; stub = p.basename(entity.path); } else { //TODO: Handle unknown type _log.severe('Unknown file entity. Not a file, directory or link.'); type = '[]'; stub = ''; } var href = stub; if (relative.isNotEmpty) { href = '/$relative/$stub'; } if (entity is Directory) { if (href == '') { href = '/'; } else { href += '/'; } } href = Uri.encodeFull(href); res.write('
  • $type $stub
  • '); } res.write(''); return false; } return true; } void _ensureContentTypeAllowed(String mimeType, RequestContext req) { var value = req.headers?.value('accept'); var acceptable = value == null || value.isNotEmpty != true || (mimeType.isNotEmpty == true && value.contains(mimeType) == true) || value.contains('*/*') == true; if (!acceptable) { _log.severe('Mime type [$value] is not supported'); throw AngelHttpException( //UnsupportedError( // 'Client requested $value, but server wanted to send $mimeType.'), errors: [ 'Client requested $value, but server wanted to send $mimeType.' ], statusCode: 406, message: '406 Not Acceptable'); } } /// Writes the contents of a file to a response. Future serveFile( File file, FileStat stat, RequestContext req, ResponseContext res) async { res.headers['accept-ranges'] = 'bytes'; if (callback != null) { return await req.app?.executeHandler( (RequestContext req, ResponseContext res) => callback!(file, req, res), req, res) ?? true; } var type = app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream'; res.headers['accept-ranges'] = 'bytes'; _ensureContentTypeAllowed(type, req); res.headers['accept-ranges'] = 'bytes'; res.contentType = MediaType.parse(type); if (useBuffer == true) res.useBuffer(); if (req.headers == null) { _log.severe('Missing headers in the RequestContext'); throw ArgumentError('Missing headers in the RequestContext'); } var reqHeaders = req.headers!; if (reqHeaders.value('range')?.startsWith('bytes=') != true) { await res.streamFile(file); } else { var header = RangeHeader.parse(reqHeaders.value('range')!); var items = RangeHeader.foldItems(header.items); header = RangeHeader(items); var totalFileSize = await file.length(); for (var item in header.items) { var invalid = false; if (item.start != -1) { invalid = item.end != -1 && item.end < item.start; } else { invalid = item.end == -1; } if (invalid) { throw AngelHttpException( //Exception('Semantically invalid, or unbounded range.'), errors: ['Semantically invalid, or unbounded range.'], statusCode: 416, message: 'Semantically invalid, or unbounded range.'); } // Ensure it's within range. if (item.start >= totalFileSize || item.end >= totalFileSize) { throw AngelHttpException( //Exception('Given range $item is out of bounds.'), errors: ['Given range $item is out of bounds.'], statusCode: 416, message: 'Given range $item is out of bounds.'); } } if (header.items.isEmpty) { throw AngelHttpException( statusCode: 416, message: '`Range` header may not be empty.'); } else if (header.items.length == 1) { var item = header.items[0]; Stream> stream; var len = 0; var 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); } } res.contentType = MediaType.parse( 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.cast>().pipe(res); return false; } else { var transformer = RangeHeaderTransformer( header, app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream', await file.length()); res.statusCode = 206; res.headers['content-length'] = transformer.computeContentLength(totalFileSize).toString(); res.contentType = MediaType( 'multipart', 'byteranges', {'boundary': transformer.boundary}); await file .openRead() .cast>() .transform(transformer) .pipe(res); return false; } } return false; } }