From 585d497b1596727191ba4ea0791190f3cb0a5aa4 Mon Sep 17 00:00:00 2001 From: Tobe O Date: Wed, 14 Nov 2018 00:43:47 -0500 Subject: [PATCH] 2.1.0 --- CHANGELOG.md | 4 ++ README.md | 4 +- lib/src/cache.dart | 100 ++++++++++++++++++++++---------- lib/src/virtual_directory.dart | 101 ++++++++++++++++++++++++++++++--- pubspec.yaml | 7 ++- 5 files changed, 177 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f7b2963a..0f8a1e35 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,7 @@ +# 2.1.0 +* Include support for the `Range` header. +* Use MD5 for etags, instead of a weak ETag. + # 2.0.2 * Fixed invalid HTML for directory listings. diff --git a/README.md b/README.md index be8aba6b..288e05d9 100644 --- a/README.md +++ b/README.md @@ -2,7 +2,9 @@ [![Pub](https://img.shields.io/pub/v/angel_static.svg)](https://pub.dartlang.org/packages/angel_static) [![build status](https://travis-ci.org/angel-dart/static.svg?branch=master)](https://travis-ci.org/angel-dart/static) -Static server middleware for Angel. +Static server infrastructure for Angel. + +*Can also handle `Range` requests now, making it suitable for media streaming, ex. music, video, etc.* # Installation In `pubspec.yaml`: diff --git a/lib/src/cache.dart b/lib/src/cache.dart index 01cb4548..3cede6cf 100644 --- a/lib/src/cache.dart +++ b/lib/src/cache.dart @@ -1,13 +1,14 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io' show HttpDate; import 'package:angel_framework/angel_framework.dart'; +import 'package:convert/convert.dart'; +import 'package:crypto/crypto.dart'; import 'package:file/file.dart'; import 'virtual_directory.dart'; -/// Generates a weak ETag from the given buffer. -String weakEtag(List buf) { - return 'W/${buf.length}' + base64Url.encode(buf.take(50).toList()); +/// Generates an MD5 ETag from the given buffer. +String md5Etag(List buf) { + return hex.encode(md5.convert(buf).bytes); } /// Returns a string representation of the given [CacheAccessLevel]. @@ -67,6 +68,8 @@ class CachingVirtualDirectory extends VirtualDirectory { @override Future serveFile( File file, FileStat stat, RequestContext req, ResponseContext res) { + res.headers['accept-ranges'] = 'bytes'; + if (onlyInProduction == true && req.app.isProduction != true) { return super.serveFile(file, stat, req, res); } @@ -82,8 +85,55 @@ class CachingVirtualDirectory extends VirtualDirectory { res.headers['cache-control'] = 'private, max-age=0, no-cache'; return super.serveFile(file, stat, req, res); } else { + var ifModified = req.headers.ifModifiedSince; + bool ifRange = false; + + try { + ifModified = HttpDate.parse(req.headers.value('if-range')); + ifRange = true; + } catch (_) { + // Fail silently... + } + + if (ifModified != null) { + try { + var ifModifiedSince = ifModified; + + if (ifModifiedSince.compareTo(stat.modified) >= 0) { + res.statusCode = 304; + setCachedHeaders(stat.modified, req, res); + + if (useEtags && _etags.containsKey(file.absolute.path)) + res.headers['ETag'] = _etags[file.absolute.path]; + + if (ifRange) { + // Send the 206 like normal + res.statusCode = 206; + return super.serveFile(file, stat, req, res); + } + + return new Future.value(false); + } else if (ifRange) { + // Return 200, just send the whole thing. + return res.streamFile(file).then((_) => false); + } + } catch (_) { + throw new AngelHttpException.badRequest( + message: + 'Invalid date for ${ifRange ? 'if-range' : 'if-not-modified-since'} header.'); + } + } + + // If-modified didn't work; try etags + if (useEtags == true) { var etagsToMatchAgainst = req.headers['if-none-match']; + ifRange = false; + + if (etagsToMatchAgainst?.isNotEmpty != true) { + etagsToMatchAgainst = req.headers['if-range']; + ifRange = etagsToMatchAgainst?.isNotEmpty == true; + } if (etagsToMatchAgainst?.isNotEmpty == true) { bool hasBeenModified = false; @@ -97,38 +147,30 @@ class CachingVirtualDirectory extends VirtualDirectory { } } - if (!hasBeenModified) { - res.statusCode = 304; - setCachedHeaders(stat.modified, req, res); - return new Future.value(false); + if (!ifRange) { + if (!hasBeenModified) { + res.statusCode = 304; + setCachedHeaders(stat.modified, req, res); + return new Future.value(false); + } + } else { + if (!hasBeenModified) { + // Continue serving like a regular range... + return super.serveFile(file, stat, req, res); + } else { + // Otherwise, send the whole thing. + return res.streamFile(file).then((_) => false); + } } } } - if (req.headers.ifModifiedSince != null) { - try { - var ifModifiedSince = req.headers.ifModifiedSince; - - if (ifModifiedSince.compareTo(stat.modified) >= 0) { - res.statusCode = 304; - setCachedHeaders(stat.modified, req, res); - - if (_etags.containsKey(file.absolute.path)) - res.headers['ETag'] = _etags[file.absolute.path]; - - return new Future.value(false); - } - } catch (_) { - throw new AngelHttpException.badRequest( - message: 'Invalid date for If-Modified-Since header.'); - } - } - return file.readAsBytes().then((buf) { - var etag = _etags[file.absolute.path] = weakEtag(buf); + if (useEtags) { + res.headers['ETag'] = _etags[file.absolute.path] = md5Etag(buf); + } //res.statusCode = 200; res.headers - ..['ETag'] = etag ..['content-type'] = res.app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream'; setCachedHeaders(stat.modified, req, res); diff --git a/lib/src/virtual_directory.dart b/lib/src/virtual_directory.dart index 0a5a6d13..db74049e 100644 --- a/lib/src/virtual_directory.dart +++ b/lib/src/virtual_directory.dart @@ -3,6 +3,7 @@ import 'package:angel_framework/angel_framework.dart'; import 'package:file/file.dart'; import 'package:http_parser/http_parser.dart'; import 'package:path/path.dart' as p; +import 'package:range_header/range_header.dart'; final RegExp _param = new RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); @@ -223,22 +224,108 @@ class VirtualDirectory { /// Writes the contents of a file to a response. Future serveFile( File file, FileStat stat, RequestContext req, ResponseContext res) async { - if (req.method == 'HEAD') return false; + if (req.method == 'HEAD') { + res.headers['accept-ranges'] = 'bytes'; + return false; + } if (callback != null) { - var r = callback(file, req, res); - r = r is Future ? await r : r; - return r == true; - //if (r != null && r != true) return r; + return await req.app.executeHandler( + (RequestContext req, ResponseContext res) => callback(file, req, res), + req, + res); } 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 = new MediaType.parse(type); - if (useBuffer == true) res.useBuffer(); - await res.streamFile(file); + + if (req.headers.value('range')?.startsWith('bytes ') != true) { + await res.streamFile(file); + } else { + var header = new RangeHeader.parse(req.headers.value('range')); + var items = RangeHeader.foldItems(header.items); + var totalFileSize = await file.length(); + header = new RangeHeader(items); + + for (var item in header.items) { + bool invalid = false; + + if (item.start != -1) { + invalid = item.end != -1 && item.end < item.start; + } else + invalid = item.end == -1; + + if (invalid) { + throw new AngelHttpException( + new Exception("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 new AngelHttpException( + new Exception("Given range $item is out of bounds."), + statusCode: 416, + message: "Given range $item is out of bounds."); + } + } + + if (header.items.isEmpty) { + throw new AngelHttpException(null, + statusCode: 416, message: '`Range` header may not be empty.'); + } else if (header.items.length == 1) { + var item = header.items[0]; + Stream> 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); + } + } + + res.contentType = new 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.pipe(res); + return false; + } else { + var transformer = new 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 = new MediaType( + 'multipart', 'byteranges', {'boundary': transformer.boundary}); + await file.openRead().transform(transformer).pipe(res); + return false; + } + } + return false; } } diff --git a/pubspec.yaml b/pubspec.yaml index 1b6da222..a71aa605 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -1,15 +1,18 @@ name: angel_static -description: Static server middleware for Angel. Use this to serve files to users. +description: Static server middleware for Angel. Also capable of serving Range responses. environment: sdk: ">=1.8.0 <3.0.0" homepage: https://github.com/angel-dart/static author: Tobe O -version: 2.0.2 +version: 2.1.0 dependencies: angel_framework: ^2.0.0-alpha + convert: ^2.0.0 + crypto: ^2.0.0 file: ^5.0.0 http_parser: ^3.0.0 path: ^1.4.2 + range_header: ^2.0.0 dev_dependencies: angel_test: ^2.0.0-alpha http: