diff --git a/packages/cors/.gitignore b/packages/cors/.gitignore new file mode 100644 index 00000000..fc12d1b3 --- /dev/null +++ b/packages/cors/.gitignore @@ -0,0 +1,74 @@ +# See https://www.dartlang.org/tools/private-files.html + +# Files and directories created by pub +.buildlog +.packages +.project +.pub/ +.scripts-bin/ +build/ +**/packages/ + +# Files created by dart2js +# (Most Dart developers will use pub build to compile Dart, use/modify these +# rules if you intend to use dart2js directly +# Convention is to use extension '.dart.js' for Dart compiled to Javascript to +# differentiate from explicit Javascript files) +*.dart.js +*.part.js +*.js.deps +*.js.map +*.info.json + +# Directory created by dartdoc +doc/api/ + +# Don't commit pubspec lock file +# (Library packages only! Remove pattern if developing an application package) +pubspec.lock +### JetBrains template +# Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and Webstorm +# Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 + +# User-specific stuff: +.idea/**/workspace.xml +.idea/**/tasks.xml +.idea/dictionaries + +# Sensitive or high-churn files: +.idea/**/dataSources/ +.idea/**/dataSources.ids +.idea/**/dataSources.xml +.idea/**/dataSources.local.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: +*.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 +fabric.properties + +.dart_tool diff --git a/packages/cors/.travis.yml b/packages/cors/.travis.yml new file mode 100644 index 00000000..de2210c9 --- /dev/null +++ b/packages/cors/.travis.yml @@ -0,0 +1 @@ +language: dart \ No newline at end of file diff --git a/packages/cors/CHANGELOG.md b/packages/cors/CHANGELOG.md new file mode 100644 index 00000000..6c8e8eea --- /dev/null +++ b/packages/cors/CHANGELOG.md @@ -0,0 +1,2 @@ +# 2.0.0 +* Updates for Dart 2 and Angel 2. \ No newline at end of file diff --git a/packages/cors/LICENSE b/packages/cors/LICENSE new file mode 100644 index 00000000..15fe44bd --- /dev/null +++ b/packages/cors/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2016 The Angel Framework + +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/cors/README.md b/packages/cors/README.md new file mode 100644 index 00000000..54f71173 --- /dev/null +++ b/packages/cors/README.md @@ -0,0 +1,8 @@ +# cors +[![Pub](https://img.shields.io/pub/v/angel_cors.svg)](https://pub.dartlang.org/packages/angel_cors) +[![build status](https://travis-ci.org/angel-dart/cors.svg)](https://travis-ci.org/angel-dart/cors) + +Angel CORS middleware. +Port of [the original Express CORS middleware](https://github.com/expressjs/cors). + +For complete example usage, see the [example file](example/example.dart). \ No newline at end of file diff --git a/packages/cors/analysis_options.yaml b/packages/cors/analysis_options.yaml new file mode 100644 index 00000000..c230cee7 --- /dev/null +++ b/packages/cors/analysis_options.yaml @@ -0,0 +1,4 @@ +include: package:pedantic/analysis_options.yaml +analyzer: + strong-mode: + implicit-casts: false \ No newline at end of file diff --git a/packages/cors/example/example.dart b/packages/cors/example/example.dart new file mode 100644 index 00000000..42b18494 --- /dev/null +++ b/packages/cors/example/example.dart @@ -0,0 +1,51 @@ +import 'dart:async'; +import 'package:angel_cors/angel_cors.dart'; +import 'package:angel_framework/angel_framework.dart'; + +Future configureServer(Angel app) async { + // The default options will allow CORS for any request. + // Combined with `fallback`, you can enable CORS application-wide. + app.fallback(cors()); + + // You can also enable CORS for a single route. + app.get( + '/my_api', + chain([ + cors(), + (req, res) { + // Request handling logic here... + } + ]), + ); + + // Likewise, you can apply CORS to a group. + app.chain([cors()]).group('/api', (router) { + router.get('/version', (req, res) => 'v0'); + }); + + // Of course, you can configure CORS. + // The following is just a subset of the available options; + app.fallback(cors( + CorsOptions( + origin: 'https://pub.dartlang.org', successStatus: 200, // default 204 + allowedHeaders: ['POST'], + preflightContinue: false, // default false + ), + )); + + // You can specify the origin in different ways: + CorsOptions(origin: 'https://pub.dartlang.org'); + CorsOptions(origin: ['https://example.org', 'http://foo.bar']); + CorsOptions(origin: RegExp(r'^foo\.[^$]+')); + CorsOptions(origin: (String s) => s.length == 4); + + // Lastly, you can dynamically configure CORS: + app.fallback(dynamicCors((req, res) { + return CorsOptions( + origin: [ + req.headers.value('origin') ?? 'https://pub.dartlang.org', + RegExp(r'\.com$'), + ], + ); + })); +} diff --git a/packages/cors/lib/angel_cors.dart b/packages/cors/lib/angel_cors.dart new file mode 100644 index 00000000..8d376f89 --- /dev/null +++ b/packages/cors/lib/angel_cors.dart @@ -0,0 +1,100 @@ +/// Angel CORS middleware. +library angel_cors; + +import 'dart:async'; + +import 'package:angel_framework/angel_framework.dart'; +import 'src/cors_options.dart'; +export 'src/cors_options.dart'; + +/// Determines if a request origin is CORS-able. +typedef bool _CorsFilter(String origin); + +bool _isOriginAllowed(String origin, [allowedOrigin]) { + allowedOrigin ??= []; + if (allowedOrigin is Iterable) { + return allowedOrigin.any((x) => _isOriginAllowed(origin, x)); + } else if (allowedOrigin is String) { + return origin == allowedOrigin; + } else if (allowedOrigin is RegExp) { + return origin != null && allowedOrigin.hasMatch(origin); + } else if (origin != null && allowedOrigin is _CorsFilter) { + return allowedOrigin(origin); + } else { + return allowedOrigin != false; + } +} + +/// On-the-fly configures the [cors] handler. Use this when the context of the surrounding request +/// is necessary to decide how to handle an incoming request. +Future Function(RequestContext, ResponseContext) dynamicCors( + FutureOr Function(RequestContext, ResponseContext) f) { + return (req, res) async { + var opts = await f(req, res); + var handler = cors(opts); + return await handler(req, res); + }; +} + +/// Applies the given [CorsOptions]. +Future Function(RequestContext, ResponseContext) cors( + [CorsOptions options]) { + options ??= CorsOptions(); + + return (req, res) async { + // access-control-allow-credentials + if (options.credentials == true) { + res.headers['access-control-allow-credentials'] = 'true'; + } + + // access-control-allow-headers + if (req.method == 'OPTIONS' && options.allowedHeaders.isNotEmpty) { + res.headers['access-control-allow-headers'] = + options.allowedHeaders.join(','); + } else if (req.headers['access-control-request-headers'] != null) { + res.headers['access-control-allow-headers'] = + req.headers.value('access-control-request-headers'); + } + + // access-control-expose-headers + if (options.exposedHeaders.isNotEmpty) { + res.headers['access-control-expose-headers'] = + options.exposedHeaders.join(','); + } + + // access-control-allow-methods + if (req.method == 'OPTIONS' && options.methods.isNotEmpty) { + res.headers['access-control-allow-methods'] = options.methods.join(','); + } + + // access-control-max-age + if (req.method == 'OPTIONS' && options.maxAge != null) { + res.headers['access-control-max-age'] = options.maxAge.toString(); + } + + // access-control-allow-origin + if (options.origin == false || options.origin == '*') { + res.headers['access-control-allow-origin'] = '*'; + } else if (options.origin is String) { + res + ..headers['access-control-allow-origin'] = options.origin as String + ..headers['vary'] = 'origin'; + } else { + bool isAllowed = + _isOriginAllowed(req.headers.value('origin'), options.origin); + + res.headers['access-control-allow-origin'] = + isAllowed ? req.headers.value('origin') : false.toString(); + + if (isAllowed) { + res.headers['vary'] = 'origin'; + } + } + + if (req.method != 'OPTIONS') return true; + res.statusCode = options.successStatus ?? 204; + res.contentLength = 0; + await res.close(); + return options.preflightContinue; + }; +} diff --git a/packages/cors/lib/src/cors_options.dart b/packages/cors/lib/src/cors_options.dart new file mode 100644 index 00000000..34f07f40 --- /dev/null +++ b/packages/cors/lib/src/cors_options.dart @@ -0,0 +1,73 @@ +/// CORS configuration options. +/// +/// The default configuration is the equivalent of: +/// +///```json +///{ +/// "origin": "*", +/// "methods": "GET,HEAD,PUT,PATCH,POST,DELETE", +/// "preflightContinue": false +///} +/// ``` +class CorsOptions { + /// Configures the **Access-Control-Allow-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Type,Authorization') or an array (ex: `['Content-Type', 'Authorization']`). If not specified, defaults to reflecting the headers specified in the request's **Access-Control-Request-Headers** header. + final List allowedHeaders = []; + + /// Configures the **Access-Control-Allow-Credentials** CORS header. Set to `true` to pass the header, otherwise it is omitted. + final bool credentials; + + /// Configures the **Access-Control-Expose-Headers** CORS header. Expects a comma-delimited string (ex: 'Content-Range,X-Content-Range') or an array (ex: `['Content-Range', 'X-Content-Range']`). If not specified, no custom headers are exposed. + final List exposedHeaders = []; + + /// Configures the **Access-Control-Max-Age** CORS header. Set to an integer to pass the header, otherwise it is omitted. + /// + /// Default: `null` + final int maxAge; + + /// The status code to be sent on successful `OPTIONS` requests, if [preflightContinue] is `false`. + final int successStatus; + + /// Configures the **Access-Control-Allow-Methods** CORS header. Expects a comma-delimited string (ex: 'GET,PUT,POST') or an array (ex: `['GET', 'PUT', 'POST']`). + /// + /// Default: `['GET', 'HEAD', 'PUT', 'PATCH', 'POST', 'DELETE˝']` + final List methods = []; + + /// Configures the **Access-Control-Allow-Origin** CORS header. + /// Possible values: + /// - `Boolean` - set `origin` to `true` to reflect the [request origin](http://tools.ietf.org/html/draft-abarth-origin-09), as defined by `req.header('Origin')`, or set it to `false` to disable CORS. + /// - `String` - set `origin` to a specific origin. For example if you set it to `"http://example.com"` only requests from "http://example.com" will be allowed. + /// - `RegExp` - set `origin` to a regular expression pattern which will be used to test the request origin. If it's a match, the request origin will be reflected. For example the pattern `/example\.com$/` will reflect any request that is coming from an origin ending with "example.com". + /// - `Array` - set `origin` to an array of valid origins. Each origin can be a `String` or a `RegExp`. For example `["http://example1.com", /\.example2\.com$/]` will accept any request from "http://example1.com" or from a subdomain of "example2.com". + /// - `bool Function(String)` - set `origin` to a function implementing some custom logic. The function takes the request origin as the first parameter and returns a [bool]. + /// + /// Default: `'*'` + final origin; + + /// If `false`, then the [cors] handler will terminate the response after performing its logic. + /// + /// Default: `false` + final bool preflightContinue; + + CorsOptions( + {Iterable allowedHeaders = const [], + this.credentials, + this.maxAge, + Iterable methods = const [ + 'GET', + 'HEAD', + 'PUT', + 'PATCH', + 'POST', + 'DELETE' + ], + this.origin = '*', + this.successStatus = 204, + this.preflightContinue = false, + Iterable exposedHeaders = const []}) { + if (allowedHeaders != null) this.allowedHeaders.addAll(allowedHeaders); + + if (methods != null) this.methods.addAll(methods); + + if (exposedHeaders != null) this.exposedHeaders.addAll(exposedHeaders); + } +} diff --git a/packages/cors/pubspec.yaml b/packages/cors/pubspec.yaml new file mode 100644 index 00000000..49d7a66c --- /dev/null +++ b/packages/cors/pubspec.yaml @@ -0,0 +1,14 @@ +author: Tobe O +description: Angel CORS middleware. Port of expressjs/cors to the Angel framework. +environment: + sdk: ">=2.0.0 <3.0.0" +homepage: https://github.com/angel-dart/cors.git +name: angel_cors +version: 2.0.0 +dependencies: + angel_framework: ^2.0.0-alpha +dev_dependencies: + angel_test: ^2.0.0 + http: ^0.12.0 + pedantic: ^1.0.0 + test: ^1.0.0 diff --git a/packages/cors/test/cors_test.dart b/packages/cors/test/cors_test.dart new file mode 100644 index 00000000..e347a013 --- /dev/null +++ b/packages/cors/test/cors_test.dart @@ -0,0 +1,174 @@ +import 'package:angel_framework/angel_framework.dart'; +import 'package:angel_framework/http.dart'; +import 'package:angel_cors/angel_cors.dart'; +import 'package:http/http.dart' as http; +import 'package:test/test.dart'; + +main() { + Angel app; + AngelHttp server; + http.Client client; + + setUp(() async { + app = Angel() + ..options('/credentials', cors(CorsOptions(credentials: true))) + ..options('/credentials_d', + dynamicCors((req, res) => CorsOptions(credentials: true))) + ..options( + '/headers', cors(CorsOptions(exposedHeaders: ['x-foo', 'x-bar']))) + ..options('/max_age', cors(CorsOptions(maxAge: 250))) + ..options('/methods', cors(CorsOptions(methods: ['GET', 'POST']))) + ..get( + '/originl', + chain([ + cors(CorsOptions( + origin: ['foo.bar', 'baz.quux'], + )), + (req, res) => req.headers['origin'] + ])) + ..get( + '/origins', + chain([ + cors(CorsOptions( + origin: 'foo.bar', + )), + (req, res) => req.headers['origin'] + ])) + ..get( + '/originr', + chain([ + cors(CorsOptions( + origin: RegExp(r'^foo\.[^x]+$'), + )), + (req, res) => req.headers['origin'] + ])) + ..get( + '/originp', + chain([ + cors(CorsOptions( + origin: (String s) => s.endsWith('.bar'), + )), + (req, res) => req.headers['origin'] + ])) + ..options('/status', cors(CorsOptions(successStatus: 418))) + ..fallback(cors(CorsOptions())) + ..post('/', (req, res) async { + res.write('hello world'); + }) + ..fallback((req, res) => throw AngelHttpException.notFound()); + + server = AngelHttp(app); + await server.startServer('127.0.0.1', 0); + client = http.Client(); + }); + + tearDown(() async { + await server.close(); + app = null; + client = null; + }); + + test('status 204 by default', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/max_age')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.statusCode, 204); + }); + + test('content length 0 by default', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/max_age')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.contentLength, 0); + }); + + test('custom successStatus', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/status')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.statusCode, 418); + }); + + test('max age', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/max_age')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-max-age'], '250'); + }); + + test('methods', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/methods')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-allow-methods'], 'GET,POST'); + }); + + test('dynamicCors.credentials', () async { + var rq = + http.Request('OPTIONS', server.uri.replace(path: '/credentials_d')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-allow-credentials'], 'true'); + }); + + test('credentials', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/credentials')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-allow-credentials'], 'true'); + }); + + test('exposed headers', () async { + var rq = http.Request('OPTIONS', server.uri.replace(path: '/headers')); + var response = await client.send(rq).then(http.Response.fromStream); + expect(response.headers['access-control-expose-headers'], 'x-foo,x-bar'); + }); + + test('invalid origin', () async { + var response = await client.get(server.uri.replace(path: '/originl'), + headers: {'origin': 'foreign'}); + expect(response.headers['access-control-allow-origin'], 'false'); + }); + + test('list origin', () async { + var response = await client.get(server.uri.replace(path: '/originl'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + response = await client.get(server.uri.replace(path: '/originl'), + headers: {'origin': 'baz.quux'}); + expect(response.headers['access-control-allow-origin'], 'baz.quux'); + expect(response.headers['vary'], 'origin'); + }); + + test('string origin', () async { + var response = await client.get(server.uri.replace(path: '/origins'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + }); + + test('regex origin', () async { + var response = await client.get(server.uri.replace(path: '/originr'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + }); + + test('predicate origin', () async { + var response = await client.get(server.uri.replace(path: '/originp'), + headers: {'origin': 'foo.bar'}); + expect(response.headers['access-control-allow-origin'], 'foo.bar'); + expect(response.headers['vary'], 'origin'); + }); + + test('POST works', () async { + final response = await client.post(server.uri); + expect(response.statusCode, equals(200)); + print('Response: ${response.body}'); + print('Headers: ${response.headers}'); + expect(response.headers['access-control-allow-origin'], equals('*')); + }); + + test('mirror headers', () async { + final response = await client + .post(server.uri, headers: {'access-control-request-headers': 'foo'}); + expect(response.statusCode, equals(200)); + print('Response: ${response.body}'); + print('Headers: ${response.headers}'); + expect(response.headers['access-control-allow-headers'], equals('foo')); + }); +}