2.1.0
This commit is contained in:
parent
bf8071d34c
commit
585d497b15
5 changed files with 177 additions and 39 deletions
|
@ -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.
|
||||||
|
|
||||||
|
|
|
@ -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`:
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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:
|
||||||
|
|
Loading…
Reference in a new issue