This commit is contained in:
Tobe O 2018-11-14 00:43:47 -05:00
parent bf8071d34c
commit 585d497b15
5 changed files with 177 additions and 39 deletions

View file

@ -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 # 2.0.2
* Fixed invalid HTML for directory listings. * Fixed invalid HTML for directory listings.

View file

@ -2,7 +2,9 @@
[![Pub](https://img.shields.io/pub/v/angel_static.svg)](https://pub.dartlang.org/packages/angel_static) [![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) [![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 # Installation
In `pubspec.yaml`: In `pubspec.yaml`:

View file

@ -1,13 +1,14 @@
import 'dart:async'; import 'dart:async';
import 'dart:convert';
import 'dart:io' show HttpDate; import 'dart:io' show HttpDate;
import 'package:angel_framework/angel_framework.dart'; import 'package:angel_framework/angel_framework.dart';
import 'package:convert/convert.dart';
import 'package:crypto/crypto.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'virtual_directory.dart'; import 'virtual_directory.dart';
/// Generates a weak ETag from the given buffer. /// Generates an MD5 ETag from the given buffer.
String weakEtag(List<int> buf) { String md5Etag(List<int> buf) {
return 'W/${buf.length}' + base64Url.encode(buf.take(50).toList()); return hex.encode(md5.convert(buf).bytes);
} }
/// Returns a string representation of the given [CacheAccessLevel]. /// Returns a string representation of the given [CacheAccessLevel].
@ -67,6 +68,8 @@ class CachingVirtualDirectory extends VirtualDirectory {
@override @override
Future<bool> serveFile( Future<bool> serveFile(
File file, FileStat stat, RequestContext req, ResponseContext res) { File file, FileStat stat, RequestContext req, ResponseContext res) {
res.headers['accept-ranges'] = 'bytes';
if (onlyInProduction == true && req.app.isProduction != true) { if (onlyInProduction == true && req.app.isProduction != true) {
return super.serveFile(file, stat, req, res); 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'; res.headers['cache-control'] = 'private, max-age=0, no-cache';
return super.serveFile(file, stat, req, res); return super.serveFile(file, stat, req, res);
} else { } 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) { if (useEtags == true) {
var etagsToMatchAgainst = req.headers['if-none-match']; 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) { if (etagsToMatchAgainst?.isNotEmpty == true) {
bool hasBeenModified = false; bool hasBeenModified = false;
@ -97,38 +147,30 @@ class CachingVirtualDirectory extends VirtualDirectory {
} }
} }
if (!hasBeenModified) { if (!ifRange) {
res.statusCode = 304; if (!hasBeenModified) {
setCachedHeaders(stat.modified, req, res); res.statusCode = 304;
return new Future.value(false); 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) { 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.statusCode = 200;
res.headers res.headers
..['ETag'] = etag
..['content-type'] = res.app.mimeTypeResolver.lookup(file.path) ?? ..['content-type'] = res.app.mimeTypeResolver.lookup(file.path) ??
'application/octet-stream'; 'application/octet-stream';
setCachedHeaders(stat.modified, req, res); setCachedHeaders(stat.modified, req, res);

View file

@ -3,6 +3,7 @@ import 'package:angel_framework/angel_framework.dart';
import 'package:file/file.dart'; import 'package:file/file.dart';
import 'package:http_parser/http_parser.dart'; import 'package:http_parser/http_parser.dart';
import 'package:path/path.dart' as p; 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 _param = new RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?');
final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)'); final RegExp _straySlashes = new RegExp(r'(^/+)|(/+$)');
@ -223,22 +224,108 @@ class VirtualDirectory {
/// Writes the contents of a file to a response. /// Writes the contents of a file to a response.
Future<bool> serveFile( Future<bool> serveFile(
File file, FileStat stat, RequestContext req, ResponseContext res) async { 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) { if (callback != null) {
var r = callback(file, req, res); return await req.app.executeHandler(
r = r is Future ? await r : r; (RequestContext req, ResponseContext res) => callback(file, req, res),
return r == true; req,
//if (r != null && r != true) return r; res);
} }
var type = var type =
app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream'; app.mimeTypeResolver.lookup(file.path) ?? 'application/octet-stream';
res.headers['accept-ranges'] = 'bytes';
_ensureContentTypeAllowed(type, req); _ensureContentTypeAllowed(type, req);
res.headers['accept-ranges'] = 'bytes';
res.contentType = new MediaType.parse(type); res.contentType = new MediaType.parse(type);
if (useBuffer == true) res.useBuffer(); 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<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);
}
}
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; return false;
} }
} }

View file

@ -1,15 +1,18 @@
name: angel_static 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: environment:
sdk: ">=1.8.0 <3.0.0" sdk: ">=1.8.0 <3.0.0"
homepage: https://github.com/angel-dart/static homepage: https://github.com/angel-dart/static
author: Tobe O <thosakwe@gmail.com> author: Tobe O <thosakwe@gmail.com>
version: 2.0.2 version: 2.1.0
dependencies: dependencies:
angel_framework: ^2.0.0-alpha angel_framework: ^2.0.0-alpha
convert: ^2.0.0
crypto: ^2.0.0
file: ^5.0.0 file: ^5.0.0
http_parser: ^3.0.0 http_parser: ^3.0.0
path: ^1.4.2 path: ^1.4.2
range_header: ^2.0.0
dev_dependencies: dev_dependencies:
angel_test: ^2.0.0-alpha angel_test: ^2.0.0-alpha
http: http: