diff --git a/packages/static/.gitignore b/packages/static/.gitignore new file mode 100644 index 00000000..5292e53c --- /dev/null +++ b/packages/static/.gitignore @@ -0,0 +1,68 @@ +# Created by .ignore support plugin (hsz.mobi) +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio + +*.iml + +## Directory-based project format: +.idea/ +# if you remove the above rule, at least ignore the following: + +# User-specific stuff: +# .idea/workspace.xml +# .idea/tasks.xml +# .idea/dictionaries + +# Sensitive or high-churn files: +# .idea/dataSources.ids +# .idea/dataSources.xml +# .idea/sqlDataSources.xml +# .idea/dynamic.xml +# .idea/uiDesigner.xml + +# Gradle: +# .idea/gradle.xml +# .idea/libraries + +# Mongo Explorer plugin: +# .idea/mongoSettings.xml + +## File-based project format: +*.ipr +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +### Dart template +# Don’t commit the following directories created by pub. +.buildlog +.pub/ +build/ +packages +.packages + +# Or the files created by dart2js. +*.dart.js +*.js_ +*.js.deps +*.js.map + +# Include when developing application packages. +pubspec.lock + +.dart_tool +*.mp3 +*.mp4 \ No newline at end of file diff --git a/packages/static/.travis.yml b/packages/static/.travis.yml new file mode 100644 index 00000000..a9e2c109 --- /dev/null +++ b/packages/static/.travis.yml @@ -0,0 +1,4 @@ +language: dart +dart: + - dev + - stable \ No newline at end of file diff --git a/packages/static/CHANGELOG.md b/packages/static/CHANGELOG.md new file mode 100644 index 00000000..be8e4015 --- /dev/null +++ b/packages/static/CHANGELOG.md @@ -0,0 +1,74 @@ +# 2.1.3+2 +* Prepare for upcoming change to File.openRead() + +# 2.1.3+1 +* Apply control flow lints. + +# 2.1.3 +* Apply lints. +* Pin to Dart `>=2.0.0 <3.0.0`. +* Use at least version `2.0.0-rc.0` of `angel_framework`. + +# 2.1.2+1 +* Fix a typo that prevented `Range` requests from working. + +# 2.1.2 +* Patch support for range+streaming in Caching server. + +# 2.1.1 +* URI-encode paths in directory listing. This produces correct URL's, always. + +# 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. + +# 2.0.1 +* Remove use of `sendFile`. +* Add a `p.isWithin` check to ensure that paths do not escape the `source` directory. +* Handle `HEAD` requests. + +# 2.0.0 +* Upgrade dependencies to Angel 2 + file@5. +* Replace `useStream` with `useBuffer`. +* Remove `package:intl`, just use `HttpDate` instead. + +# 1.3.0+1 +* Dart 2 fixes. +* Enable optionally writing responses to the buffer instead of streaming. + +# 1.3.0 +* `pushState` uses `strict` mode when `accepts` is passed. + +# 1.3.0-alpha+2 +* Added an `accepts` option to `pushState`. +* Added optional directory listings. + +# 1.3.0-alpha+1 +* ETags once again only encode the first 50 bytes of files. Resolves [#27](https://github.com/angel-dart/static/issues/27). + +# 1.3.0-alpha +* Removed file transformers. +* `VirtualDirectory` is no longer an `AngelPlugin`, and instead exposes a `handleRequest` middleware. +* Added `pushState` to `VirtualDirectory`. + +# 1.2.5 +* Fixed a bug where `onlyInProduction` was not properly adhered to. +* Fixed another bug where `Accept-Encoding` was not properly adhered to. +* Setting `maxAge` to `null` will now prevent a `CachingVirtualDirectory` from sending an `Expires` header. +* Pre-built assets can now be mass-deleted with `VirtualDirectory.cleanFromDisk()`. +Resolves [#22](https://github.com/angel-dart/static/issues/22). + +# 1.2.4+1 +Fixed a bug where `Accept-Encoding` was not properly adhered to. + +# 1.2.4 +Fixes https://github.com/angel-dart/angel/issues/44. +* MIME types will now default to `application/octet-stream`. +* When `streamToIO` is `true`, the body will only be sent gzipped if the request explicitly allows it. + +# 1.2.3 +Fixed #40 and #41, which dealt with paths being improperly served when using a +`publicPath`. diff --git a/packages/static/LICENSE b/packages/static/LICENSE new file mode 100644 index 00000000..eb4ce33e --- /dev/null +++ b/packages/static/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) 2016 angel-dart + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/packages/static/README.md b/packages/static/README.md new file mode 100644 index 00000000..f20e9a1e --- /dev/null +++ b/packages/static/README.md @@ -0,0 +1,70 @@ +# 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) + +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`: + +```yaml +dependencies: + angel_static: ^2.0.0-alpha +``` + +# Usage +To serve files from a directory, you need to create a `VirtualDirectory`. +Keep in mind that `angel_static` uses `package:file` instead of `dart:io`. + +```dart +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:file/local.dart'; + +main() async { + var app = Angel(); + var fs = const LocalFileSystem(); + + // Normal static server + var vDir = VirtualDirectory(app, fs, source: Directory('./public')); + + // Send Cache-Control, ETag, etc. as well + var vDir = CachingVirtualDirectory(app, fs, source: Directory('./public')); + + // Mount the VirtualDirectory's request handler + app.fallback(vDir.handleRequest); + + // Start your server!!! + await AngelHttp(app).startServer(); +} +``` + +# Push State +`VirtualDirectory` also exposes a `pushState` method that returns a +request handler that serves the file at a given path as a fallback, unless +the user is requesting that file. This can be very useful for SPA's. + +```dart +// Create VirtualDirectory as well +var vDir = CachingVirtualDirectory(...); + +// Mount it +app.fallback(vDir.handleRequest); + +// Fallback to index.html on 404 +app.fallback(vDir.pushState('index.html')); +``` + +# Options +The `VirtualDirectory` API accepts a few named parameters: +- **source**: A `Directory` containing the files to be served. If left null, then Angel will serve either from `web` (in development) or + `build/web` (in production), depending on your `ANGEL_ENV`. +- **indexFileNames**: A `List` of filenames that should be served as index pages. Default is `['index.html']`. +- **publicPath**: To serve index files, you need to specify the virtual path under which + angel_static is serving your files. If you are not serving static files at the site root, + please include this. +- **callback**: Runs before sending a file to a client. Use this to set headers, etc. If it returns anything other than `null` or `true`, +then the callback's result will be sent to the user, instead of the file contents. \ No newline at end of file diff --git a/packages/static/analysis_options.yaml b/packages/static/analysis_options.yaml new file mode 100644 index 00000000..085be64d --- /dev/null +++ b/packages/static/analysis_options.yaml @@ -0,0 +1,8 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false +linter: + rules: + - unnecessary_const + - unnecessary_new \ No newline at end of file diff --git a/packages/static/example/main.dart b/packages/static/example/main.dart new file mode 100644 index 00000000..61791fda --- /dev/null +++ b/packages/static/example/main.dart @@ -0,0 +1,40 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; + +main(List args) async { + var app = Angel(); + var http = AngelHttp(app); + var fs = const LocalFileSystem(); + var vDir = CachingVirtualDirectory( + app, + fs, + allowDirectoryListing: true, + source: args.isEmpty ? fs.currentDirectory : fs.directory(args[0]), + maxAge: const Duration(days: 24).inSeconds, + ); + + app.mimeTypeResolver + ..addExtension('', 'text/plain') + ..addExtension('dart', 'text/dart') + ..addExtension('lock', 'text/plain') + ..addExtension('markdown', 'text/plain') + ..addExtension('md', 'text/plain') + ..addExtension('yaml', 'text/plain'); + + app.logger = Logger('example') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + app.fallback(vDir.handleRequest); + app.fallback((req, res) => throw AngelHttpException.notFound()); + + var server = await http.startServer('127.0.0.1', 3000); + print('Serving from ${vDir.source.path}'); + print('Listening at http://${server.address.address}:${server.port}'); +} diff --git a/packages/static/lib/angel_static.dart b/packages/static/lib/angel_static.dart new file mode 100644 index 00000000..4ad551a1 --- /dev/null +++ b/packages/static/lib/angel_static.dart @@ -0,0 +1,4 @@ +library angel_static; + +export 'src/cache.dart'; +export 'src/virtual_directory.dart'; diff --git a/packages/static/lib/src/cache.dart b/packages/static/lib/src/cache.dart new file mode 100644 index 00000000..0f859a39 --- /dev/null +++ b/packages/static/lib/src/cache.dart @@ -0,0 +1,182 @@ +import 'dart:async'; +import 'dart:io' show HttpDate; +import 'package:angel_framework/angel_framework.dart'; +import 'package:file/file.dart'; +import 'virtual_directory.dart'; + +/// 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 ArgumentError('Unrecognized cache access level: $accessLevel'); + } +} + +/// A `VirtualDirectory` that also sets `Cache-Control` headers. +class CachingVirtualDirectory extends VirtualDirectory { + final Map _etags = {}; + + /// Either `PUBLIC` or `PRIVATE`. + final CacheAccessLevel accessLevel; + + /// 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; + + /// The `max-age` for `Cache-Control`. + /// + /// Set this to `null` to leave no `Expires` header on responses. + final int maxAge; + + CachingVirtualDirectory(Angel app, FileSystem fileSystem, + {this.accessLevel = CacheAccessLevel.PUBLIC, + Directory source, + bool debug, + Iterable indexFileNames, + this.maxAge = 0, + this.noCache = false, + this.onlyInProduction = false, + this.useEtags = true, + bool allowDirectoryListing, + bool useBuffer = false, + String publicPath, + callback(File file, RequestContext req, ResponseContext res)}) + : super(app, fileSystem, + source: source, + indexFileNames: indexFileNames ?? ['index.html'], + publicPath: publicPath ?? '/', + callback: callback, + allowDirectoryListing: allowDirectoryListing, + useBuffer: useBuffer); + + @override + Future serveFile( + File file, FileStat stat, RequestContext req, ResponseContext res) { + res.headers['accept-ranges'] = 'bytes'; + + if (onlyInProduction == true && req.app.environment.isProduction != true) { + return super.serveFile(file, stat, req, res); + } + + bool shouldNotCache = noCache == true; + + if (!shouldNotCache) { + shouldNotCache = req.headers.value('cache-control') == 'no-cache' || + req.headers.value('pragma') == 'no-cache'; + } + + if (shouldNotCache) { + 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 Future.value(false); + } else if (ifRange) { + return super.serveFile(file, stat, req, res); + } + } catch (_) { + throw 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; + + for (var etag in etagsToMatchAgainst) { + if (etag == '*') { + hasBeenModified = true; + } else { + hasBeenModified = !_etags.containsKey(file.absolute.path) || + _etags[file.absolute.path] != etag; + } + } + + if (!ifRange) { + if (!hasBeenModified) { + res.statusCode = 304; + setCachedHeaders(stat.modified, req, res); + return Future.value(false); + } + } else { + return super.serveFile(file, stat, req, res); + } + } + } + + return file.lastModified().then((stamp) { + if (useEtags) { + res.headers['ETag'] = _etags[file.absolute.path] = + stamp.millisecondsSinceEpoch.toString(); + } + + setCachedHeaders(stat.modified, req, res); + return super.serveFile(file, stat, req, res); + }); + } + } + + void setCachedHeaders( + DateTime modified, RequestContext req, ResponseContext res) { + var privacy = accessLevelToString(accessLevel ?? CacheAccessLevel.PUBLIC); + + res.headers + ..['cache-control'] = '$privacy, max-age=${maxAge ?? 0}' + ..['last-modified'] = HttpDate.format(modified); + + if (maxAge != null) { + var expiry = DateTime.now().add(Duration(seconds: maxAge ?? 0)); + res.headers['expires'] = HttpDate.format(expiry); + } + } +} + +enum CacheAccessLevel { PUBLIC, PRIVATE } diff --git a/packages/static/lib/src/virtual_directory.dart b/packages/static/lib/src/virtual_directory.dart new file mode 100644 index 00000000..c36a5659 --- /dev/null +++ b/packages/static/lib/src/virtual_directory.dart @@ -0,0 +1,339 @@ +import 'dart:async'; +import 'dart:typed_data'; +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 = RegExp(r':([A-Za-z0-9_]+)(\((.+)\))?'); +final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)'); + +String _pathify(String path) { + var p = path.replaceAll(_straySlashes, ''); + + Map replace = {}; + + for (Match match in _param.allMatches(p)) { + if (match[3] != null) replace[match[0]] = ':${match[1]}'; + } + + replace.forEach((k, v) { + p = p.replaceAll(k, v); + }); + + return p; +} + +/// A static server plug-in. +class VirtualDirectory { + String _prefix; + 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 { + String 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; + } + + 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 (String 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('
  • ..
  • '); + + List 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 (b is Link) return a.path.compareTo(b.path); + + return 1; + }); + + for (var entity in entities) { + var stub = p.basename(entity.path); + var href = stub; + String type; + + if (entity is File) { + type = '[File]'; + } else if (entity is Directory) { + type = '[Directory]'; + } else if (entity is Link) type = '[Link]'; + + if (relative.isNotEmpty) href = '/' + relative + '/' + stub; + + if (entity is Directory) 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'); + bool acceptable = value == null || + value?.isNotEmpty != true || + (mimeType?.isNotEmpty == true && value?.contains(mimeType) == true) || + value?.contains('*/*') == true; + if (!acceptable) { + throw AngelHttpException( + UnsupportedError( + '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); + } + + 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.value('range')?.startsWith('bytes=') != true) { + await res.streamFile(file); + } else { + var header = RangeHeader.parse(req.headers.value('range')); + var items = RangeHeader.foldItems(header.items); + var totalFileSize = await file.length(); + header = 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 AngelHttpException( + 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 AngelHttpException( + Exception("Given range $item is out of bounds."), + statusCode: 416, + message: "Given range $item is out of bounds."); + } + } + + if (header.items.isEmpty) { + throw 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 = 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; + } +} diff --git a/packages/static/pubspec.yaml b/packages/static/pubspec.yaml new file mode 100644 index 00000000..33d4a02e --- /dev/null +++ b/packages/static/pubspec.yaml @@ -0,0 +1,22 @@ +name: angel_static +description: Static server middleware for Angel. Also capable of serving Range responses. +environment: + sdk: ">=2.0.0 <3.0.0" +homepage: https://github.com/angel-dart/static +author: Tobe O +version: 2.1.3+2 +dependencies: + angel_framework: ^2.0.0-rc.0 + 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: + logging: ^0.11.0 + matcher: ^0.12.0 + pedantic: ^1.0.0 + test: ^1.0.0 diff --git a/packages/static/test/HELLO.md b/packages/static/test/HELLO.md new file mode 100644 index 00000000..de89e4df --- /dev/null +++ b/packages/static/test/HELLO.md @@ -0,0 +1,2 @@ +# hello +world! \ No newline at end of file diff --git a/packages/static/test/all_test.dart b/packages/static/test/all_test.dart new file mode 100644 index 00000000..0e337b9c --- /dev/null +++ b/packages/static/test/all_test.dart @@ -0,0 +1,82 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:http/http.dart' show Client; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +main() { + Angel app; + AngelHttp http; + Directory testDir = const LocalFileSystem().directory('test'); + String url; + Client client = Client(); + + setUp(() async { + app = Angel(); + http = AngelHttp(app); + app.logger = Logger('angel')..onRecord.listen(print); + + app.fallback( + VirtualDirectory(app, const LocalFileSystem(), + source: testDir, + publicPath: '/virtual', + indexFileNames: ['index.txt']).handleRequest, + ); + + app.fallback( + VirtualDirectory(app, const LocalFileSystem(), + source: testDir, + useBuffer: true, + indexFileNames: ['index.php', 'index.txt']).handleRequest, + ); + + app.fallback((req, res) => 'Fallback'); + + app.dumpTree(showMatchers: true); + + var server = await http.startServer(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + if (http.server != null) await http.server.close(force: true); + }); + + test('can serve files, with correct Content-Type', () async { + var response = await client.get("$url/sample.txt"); + expect(response.body, equals("Hello world")); + expect(response.headers['content-type'], contains("text/plain")); + }); + + test('can serve child directories', () async { + var response = await client.get("$url/nested"); + expect(response.body, equals("Bird")); + expect(response.headers['content-type'], contains("text/plain")); + }); + + test('non-existent files are skipped', () async { + var response = await client.get("$url/nonexist.ent"); + expect(response.body, equals('"Fallback"')); + }); + + test('can match index files', () async { + var response = await client.get(url); + expect(response.body, equals("index!")); + }); + + test('virtualRoots can match index', () async { + var response = await client.get("$url/virtual"); + expect(response.body, equals("index!")); + }); + + test('chrome accept', () async { + var response = await client.get("$url/virtual", headers: { + 'accept': + 'text/html,application/xhtml+xml,application/xml;q=0.9,image/webp,*/*;q=0.8' + }); + expect(response.body, equals("index!")); + }); +} diff --git a/packages/static/test/cache_sample.dart b/packages/static/test/cache_sample.dart new file mode 100644 index 00000000..8f9c28bd --- /dev/null +++ b/packages/static/test/cache_sample.dart @@ -0,0 +1,28 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; + +main() async { + Angel app; + AngelHttp http; + Directory testDir = const LocalFileSystem().directory('test'); + app = Angel(); + http = AngelHttp(app); + + app.fallback( + CachingVirtualDirectory(app, const LocalFileSystem(), + source: testDir, + maxAge: 350, + onlyInProduction: false, + indexFileNames: ['index.txt']).handleRequest, + ); + + app.get('*', (req, res) => 'Fallback'); + + app.dumpTree(showMatchers: true); + + var server = await http.startServer(); + print('Open at http://${server.address.host}:${server.port}'); +} diff --git a/packages/static/test/cache_test.dart b/packages/static/test/cache_test.dart new file mode 100644 index 00000000..c0aba9cb --- /dev/null +++ b/packages/static/test/cache_test.dart @@ -0,0 +1,77 @@ +import 'dart:io' show HttpDate; +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:http/http.dart' show Client; +import 'package:logging/logging.dart'; +import 'package:matcher/matcher.dart'; +import 'package:test/test.dart'; + +main() { + Angel app; + AngelHttp http; + Directory testDir = const LocalFileSystem().directory('test'); + String url; + Client client = Client(); + + setUp(() async { + app = Angel(); + http = AngelHttp(app); + + app.fallback( + CachingVirtualDirectory(app, const LocalFileSystem(), + source: testDir, maxAge: 350, onlyInProduction: false, + //publicPath: '/virtual', + indexFileNames: ['index.txt']).handleRequest, + ); + + app.get('*', (req, res) => 'Fallback'); + + app.dumpTree(showMatchers: true); + + app.logger = Logger('angel_static') + ..onRecord.listen((rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }); + + var server = await http.startServer(); + url = "http://${server.address.host}:${server.port}"; + }); + + tearDown(() async { + if (http.server != null) await http.server.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( + ['etag', 'cache-control', 'expires', 'last-modified'], + everyElement(predicate( + response.headers.containsKey, 'contained in response headers'))); + }); + + test('if-modified-since', () async { + var response = await client.get("$url", headers: { + 'if-modified-since': + HttpDate.format(DateTime.now().add(Duration(days: 365))) + }); + + print('Response status: ${response.statusCode}'); + + expect(response.statusCode, equals(304)); + expect( + ['cache-control', 'expires', 'last-modified'], + everyElement(predicate( + response.headers.containsKey, 'contained in response headers'))); + }); +} diff --git a/packages/static/test/foo.mustache b/packages/static/test/foo.mustache new file mode 100644 index 00000000..f0521e3f --- /dev/null +++ b/packages/static/test/foo.mustache @@ -0,0 +1 @@ +

    {{foo}}

    \ No newline at end of file diff --git a/packages/static/test/index.txt b/packages/static/test/index.txt new file mode 100644 index 00000000..cb655f03 --- /dev/null +++ b/packages/static/test/index.txt @@ -0,0 +1 @@ +index! \ No newline at end of file diff --git a/packages/static/test/issue41_test.dart b/packages/static/test/issue41_test.dart new file mode 100644 index 00000000..b02cb245 --- /dev/null +++ b/packages/static/test/issue41_test.dart @@ -0,0 +1,64 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:file/file.dart'; +import 'package:file/local.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +final Directory swaggerUiDistDir = + const LocalFileSystem().directory('test/node_modules/swagger-ui-dist'); + +main() async { + TestClient client; + String swaggerUiCssContents, swaggerTestJsContents; + + setUp(() async { + // Load file contents + swaggerUiCssContents = await const LocalFileSystem() + .file(swaggerUiDistDir.uri.resolve('swagger-ui.css')) + .readAsString(); + swaggerTestJsContents = await const LocalFileSystem() + .file(swaggerUiDistDir.uri.resolve('test.js')) + .readAsString(); + + // Initialize app + var app = Angel(); + app.logger = Logger('angel')..onRecord.listen(print); + + app.fallback( + VirtualDirectory(app, const LocalFileSystem(), + source: swaggerUiDistDir, publicPath: 'swagger/') + .handleRequest, + ); + + app.dumpTree(); + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('prefix is not replaced in file paths', () async { + var response = await client.get('/swagger/swagger-ui.css'); + print('Response: ${response.body}'); + expect(response, hasBody(swaggerUiCssContents)); + }); + + test('get a file without prefix in name', () async { + var response = await client.get('/swagger/test.js'); + print('Response: ${response.body}'); + expect(response, hasBody(swaggerTestJsContents)); + }); + + test('trailing slash at root', () async { + var response = await client.get('/swagger'); + var body1 = response.body; + print('Response #1: $body1'); + + response = await client.get('/swagger/'); + var body2 = response.body; + print('Response #2: $body2'); + + expect(body1, body2); + }); +} diff --git a/packages/static/test/nested/index.txt b/packages/static/test/nested/index.txt new file mode 100644 index 00000000..d402a929 --- /dev/null +++ b/packages/static/test/nested/index.txt @@ -0,0 +1 @@ +Bird \ No newline at end of file diff --git a/packages/static/test/node_modules/swagger-ui-dist/index.html b/packages/static/test/node_modules/swagger-ui-dist/index.html new file mode 100644 index 00000000..6607e2bc --- /dev/null +++ b/packages/static/test/node_modules/swagger-ui-dist/index.html @@ -0,0 +1,10 @@ + + + + Swagger... + + +

    Hello!

    +Hooray for testing... + + \ No newline at end of file diff --git a/packages/static/test/node_modules/swagger-ui-dist/swagger-ui.css b/packages/static/test/node_modules/swagger-ui-dist/swagger-ui.css new file mode 100644 index 00000000..9cabb2d4 --- /dev/null +++ b/packages/static/test/node_modules/swagger-ui-dist/swagger-ui.css @@ -0,0 +1,3 @@ +html, body { + font-weight: bold; +} \ No newline at end of file diff --git a/packages/static/test/node_modules/swagger-ui-dist/test.js b/packages/static/test/node_modules/swagger-ui-dist/test.js new file mode 100644 index 00000000..3c800d3a --- /dev/null +++ b/packages/static/test/node_modules/swagger-ui-dist/test.js @@ -0,0 +1 @@ +console.log('foo'); \ No newline at end of file diff --git a/packages/static/test/push_state_test.dart b/packages/static/test/push_state_test.dart new file mode 100644 index 00000000..1312407f --- /dev/null +++ b/packages/static/test/push_state_test.dart @@ -0,0 +1,53 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_static/angel_static.dart'; +import 'package:angel_test/angel_test.dart'; +import 'package:file/memory.dart'; +import 'package:logging/logging.dart'; +import 'package:test/test.dart'; + +main() { + Angel app; + MemoryFileSystem fileSystem; + TestClient client; + + setUp(() async { + fileSystem = MemoryFileSystem(); + + var webDir = fileSystem.directory('web'); + await webDir.create(recursive: true); + + var indexFile = webDir.childFile('index.html'); + await indexFile.writeAsString('index'); + + app = Angel(); + + var vDir = VirtualDirectory( + app, + fileSystem, + source: webDir, + ); + + app + ..fallback(vDir.handleRequest) + ..fallback(vDir.pushState('index.html')) + ..fallback((req, res) => 'Fallback'); + + app.logger = Logger('push_state') + ..onRecord.listen( + (rec) { + print(rec); + if (rec.error != null) print(rec.error); + if (rec.stackTrace != null) print(rec.stackTrace); + }, + ); + + client = await connectTo(app); + }); + + tearDown(() => client.close()); + + test('serves as fallback', () async { + var response = await client.get('/nope'); + expect(response.body, 'index'); + }); +} diff --git a/packages/static/test/sample.txt b/packages/static/test/sample.txt new file mode 100644 index 00000000..70c379b6 --- /dev/null +++ b/packages/static/test/sample.txt @@ -0,0 +1 @@ +Hello world \ No newline at end of file