diff --git a/README.md b/README.md index b04d11a7..56cb5385 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # angel_static -[![version 1.1.2](https://img.shields.io/badge/pub-1.1.2-brightgreen.svg)](https://pub.dartlang.org/packages/angel_static) +[![version 1.1.3](https://img.shields.io/badge/pub-1.1.3-brightgreen.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. @@ -24,7 +24,13 @@ import 'package:angel_static/angel_static.dart'; main() async { final app = new Angel(); + + // Normal static server await app.configure(new VirtualDirectory(source: new Directory('./public'))); + + // Send Cache-Control, ETag, etc. as well + await app.configure(new CachingVirtualDirectory(source: new Directory('./public'))); + await app.startServer(); } ``` diff --git a/lib/angel_static.dart b/lib/angel_static.dart index 1f8830d4..79485e3d 100644 --- a/lib/angel_static.dart +++ b/lib/angel_static.dart @@ -1,4 +1,5 @@ library angel_static; +export 'src/cache.dart'; export 'src/serve_static.dart'; export 'src/virtual_directory.dart'; diff --git a/lib/src/cache.dart b/lib/src/cache.dart new file mode 100644 index 00000000..cb32e784 --- /dev/null +++ b/lib/src/cache.dart @@ -0,0 +1,177 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:crypto/crypto.dart'; +import 'package:intl/intl.dart'; +import 'package:mime/mime.dart'; +import 'virtual_directory.dart'; + +final DateFormat _fmt = new DateFormat('EEE, d MMM yyyy HH:mm:ss'); + +/// Formats a date (converted to UTC), ex: `Sun, 03 May 2015 23:02:37 GMT`. +String formatDateForHttp(DateTime dt) => _fmt.format(dt.toUtc()) + ' GMT'; + +/// Generates an ETag from the given buffer. +String generateEtag(List buf, {bool weak: true, Hash hash}) { + if (weak == false) { + Hash h = hash ?? md5; + return new String.fromCharCodes(h.convert(buf).bytes); + } else { + // length + first 50 bytes as base64url + return 'W/${buf.length}' + BASE64URL.encode(buf.take(50).toList()); + } +} + +/// Returns a string representation of the given [CacheAccessLevel]. +String accessLevelToString(CacheAccessLevel accessLevel) { + switch (accessLevel) { + case CacheAccessLevel.PRIVATE: + return 'private'; + case CacheAccessLevel.PUBLIC: + return 'public'; + default: + throw new ArgumentError('Unrecognized cache access level: $accessLevel'); + } +} + +/// A static server plug-in that also sets `Cache-Control` headers. +class CachingVirtualDirectory extends VirtualDirectory { + final Map _etags = {}; + + /// Either `PUBLIC` or `PRIVATE`. + final CacheAccessLevel accessLevel; + + /// Used to generate strong ETags, if [useWeakEtags] is false. + /// + /// Default: `md5`. + final Hash hash; + + /// If `true`, responses will always have `private, max-age=0` as their `Cache-Control` header. + final bool noCache; + + /// If `true` (default), `Cache-Control` headers will only be set if the application is in production mode. + final bool onlyInProduction; + + /// If `true` (default), ETags will be computed and sent along with responses. + final bool useEtags; + + /// If `false` (default: `true`), ETags will be generated via MD5 hash. + final bool useWeakEtags; + + /// The `max-age` for `Cache-Control`. + final int maxAge; + + CachingVirtualDirectory( + {this.accessLevel: CacheAccessLevel.PUBLIC, + Directory source, + bool debug, + this.hash, + Iterable indexFileNames, + this.maxAge: 0, + this.noCache: false, + this.onlyInProduction: false, + this.useEtags: true, + this.useWeakEtags: true, + String publicPath, + StaticFileCallback callback, + bool streamToIO: false}) + : super( + source: source, + debug: debug == true, + indexFileNames: indexFileNames ?? ['index.html'], + publicPath: publicPath ?? '/', + callback: callback, + streamToIO: streamToIO == true); + + @override + Future serveFile( + File file, FileStat stat, RequestContext req, ResponseContext res) { + if (onlyInProduction == true && req.app.isProduction == true) { + return super.serveFile(file, stat, req, res); + } + + if (noCache == true) { + res.headers[HttpHeaders.CACHE_CONTROL] = 'private, max-age=0, no-cache'; + return super.serveFile(file, stat, req, res); + } else { + if (useEtags == true) { + var etags = req.headers[HttpHeaders.IF_NONE_MATCH]; + + if (etags?.isNotEmpty == true) { + bool hasBeenModified = false; + + for (var etag in etags) { + if (etag == '*') + hasBeenModified = true; + else { + hasBeenModified = _etags.containsKey(file.absolute.path) && + _etags[file.absolute.path] == etag; + } + } + + if (hasBeenModified) { + res.statusCode = HttpStatus.NOT_MODIFIED; + setCachedHeaders(file, stat, req, res); + return new Future.value(false); + } + } + } + + if (req.headers[HttpHeaders.IF_MODIFIED_SINCE] != null) { + try { + var ifModifiedSince = _fmt.parse(req.headers + .value(HttpHeaders.IF_MODIFIED_SINCE) + .replaceAll('GMT', '') + .trim()); + + if (ifModifiedSince.compareTo(stat.changed) > 0) { + res.statusCode = HttpStatus.NOT_MODIFIED; + setCachedHeaders(file, stat, req, res); + + if (_etags.containsKey(file.absolute.path)) + res.headers[HttpHeaders.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] = + generateEtag(buf, weak: useWeakEtags != false); + res.headers + ..[HttpHeaders.ETAG] = etag + ..[HttpHeaders.CONTENT_TYPE] = lookupMimeType(file.path); + setCachedHeaders(file, stat, req, res); + + if (useWeakEtags == false) { + res + ..statusCode = 200 + ..willCloseItself = false + ..buffer.add(buf) + ..end(); + return new Future.value(false); + } + + return super.serveFile(file, stat, req, res); + }); + } + } + + void setCachedHeaders( + File file, FileStat stat, RequestContext req, ResponseContext res) { + var privacy = accessLevelToString(accessLevel ?? CacheAccessLevel.PUBLIC); + var expiry = new DateTime.now()..add(new Duration(seconds: maxAge ?? 0)); + + res.headers + ..[HttpHeaders.CACHE_CONTROL] = '$privacy, max-age=${maxAge ?? 0}' + ..[HttpHeaders.EXPIRES] = formatDateForHttp(expiry) + ..[HttpHeaders.LAST_MODIFIED] = formatDateForHttp(stat.changed); + } +} + +enum CacheAccessLevel { PUBLIC, PRIVATE } diff --git a/lib/src/virtual_directory.dart b/lib/src/virtual_directory.dart index 48e41fc0..22d3a0cd 100644 --- a/lib/src/virtual_directory.dart +++ b/lib/src/virtual_directory.dart @@ -25,13 +25,22 @@ String _pathify(String path) { return p; } +/// A static server plug-in. class VirtualDirectory implements AngelPlugin { final bool debug; String _prefix; Directory _source; + + /// The directory to serve files from. Directory get source => _source; + + /// An optional callback to run before serving files. final StaticFileCallback callback; - final List indexFileNames; + + /// Filenames to be resolved within directories as indices. + final Iterable indexFileNames; + + /// An optional public path to map requests to. final String publicPath; /// If set to `true`, files will be streamed to `res.io`, instead of added to `res.buffer`. @@ -89,9 +98,9 @@ class VirtualDirectory implements AngelPlugin { if (stat.type == FileSystemEntityType.NOT_FOUND) return true; else if (stat.type == FileSystemEntityType.DIRECTORY) - return await serveDirectory(new Directory(absolute), req, res); + return await serveDirectory(new Directory(absolute), stat, req, res); else if (stat.type == FileSystemEntityType.FILE) - return await serveFile(new File(absolute), req, res); + return await serveFile(new File(absolute), stat, req, res); else if (stat.type == FileSystemEntityType.LINK) { var link = new Link(absolute); return await servePath(await link.resolveSymbolicLinks(), req, res); @@ -100,7 +109,7 @@ class VirtualDirectory implements AngelPlugin { } Future serveFile( - File file, RequestContext req, ResponseContext res) async { + File file, FileStat stat, RequestContext req, ResponseContext res) async { _printDebug('Sending file ${file.absolute.path}...'); _printDebug('MIME type for ${file.path}: ${lookupMimeType(file.path)}'); res.statusCode = 200; @@ -120,13 +129,13 @@ class VirtualDirectory implements AngelPlugin { return false; } - Future serveDirectory( - Directory directory, RequestContext req, ResponseContext res) async { + Future serveDirectory(Directory directory, FileStat stat, + RequestContext req, ResponseContext res) async { for (String indexFileName in indexFileNames) { final index = new File.fromUri(directory.absolute.uri.resolve(indexFileName)); if (await index.exists()) { - return await serveFile(index, req, res); + return await serveFile(index, stat, req, res); } } diff --git a/pubspec.yaml b/pubspec.yaml index 2e7ad7c6..59b2d0d0 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -4,9 +4,10 @@ environment: sdk: ">=1.19.0" homepage: https://github.com/angel-dart/angel_static author: thosakwe -version: 1.1.2 +version: 1.1.3 dependencies: angel_framework: ^1.0.0-dev + intl: ">=0.0.0 <1.0.0" mime: ^0.9.3 dev_dependencies: http: ^0.11.3 diff --git a/test/cache_sample.dart b/test/cache_sample.dart new file mode 100644 index 00000000..03321670 --- /dev/null +++ b/test/cache_sample.dart @@ -0,0 +1,24 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_static/angel_static.dart'; + +main() async { + Angel app; + Directory testDir = new Directory('test'); + app = new Angel(debug: true); + + await app.configure(new CachingVirtualDirectory( + source: testDir, + maxAge: 350, + onlyInProduction: false, + // useWeakEtags: false, + //publicPath: '/virtual', + indexFileNames: ['index.txt'])); + + app.get('*', 'Fallback'); + + app.dumpTree(showMatchers: true); + + await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); + print('Open at http://${app.httpServer.address.host}:${app.httpServer.port}'); +} diff --git a/test/cache_test.dart b/test/cache_test.dart new file mode 100644 index 00000000..b7ed5432 --- /dev/null +++ b/test/cache_test.dart @@ -0,0 +1,71 @@ +import 'dart:io'; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:http/http.dart' show Client; +import 'package:matcher/matcher.dart'; +import 'package:test/test.dart'; + +main() { + Angel app; + Directory testDir = new Directory('test'); + String url; + Client client = new Client(); + + setUp(() async { + app = new Angel(debug: true); + + await app.configure(new CachingVirtualDirectory( + source: testDir, maxAge: 350, onlyInProduction: false, + //publicPath: '/virtual', + indexFileNames: ['index.txt'])); + + app.get('*', 'Fallback'); + + app.dumpTree(showMatchers: true); + + await app.startServer(InternetAddress.LOOPBACK_IP_V4, 0); + url = "http://${app.httpServer.address.host}:${app.httpServer.port}"; + }); + + tearDown(() async { + if (app.httpServer != null) await app.httpServer.close(force: true); + }); + + test('sets etag, cache-control, expires, last-modified', () async { + var response = await client.get("$url"); + + print('Response status: ${response.statusCode}'); + print('Response body: ${response.body}'); + print('Response headers: ${response.headers}'); + + expect(response.statusCode, equals(200)); + expect( + [ + HttpHeaders.ETAG, + HttpHeaders.CACHE_CONTROL, + HttpHeaders.EXPIRES, + HttpHeaders.LAST_MODIFIED + ], + everyElement(predicate( + response.headers.containsKey, 'contained in response headers'))); + }); + + test('if-modified-since', () async { + var response = await client.get("$url", headers: { + HttpHeaders.IF_MODIFIED_SINCE: + formatDateForHttp(new DateTime.now()..add(new Duration(days: 365))) + }); + + print('Response status: ${response.statusCode}'); + + expect(response.statusCode, equals(304)); + expect( + [ + HttpHeaders.CACHE_CONTROL, + HttpHeaders.EXPIRES, + HttpHeaders.LAST_MODIFIED + ], + everyElement(predicate( + response.headers.containsKey, 'contained in response headers'))); + }); +}