2017-06-20 19:31:35 +00:00
|
|
|
import 'dart:async';
|
2022-09-19 14:28:39 +00:00
|
|
|
import 'dart:io' hide BytesBuilder;
|
|
|
|
import 'dart:typed_data';
|
2018-11-02 04:22:32 +00:00
|
|
|
import 'dart:convert';
|
2021-06-10 08:47:05 +00:00
|
|
|
import 'package:angel3_framework/angel3_framework.dart';
|
|
|
|
import 'package:angel3_framework/http.dart';
|
2018-11-02 04:22:32 +00:00
|
|
|
import 'package:http_parser/http_parser.dart';
|
|
|
|
import 'package:http/http.dart' as http;
|
2021-07-10 04:32:42 +00:00
|
|
|
import 'package:path/path.dart';
|
2016-11-23 22:03:06 +00:00
|
|
|
|
2019-05-02 23:19:30 +00:00
|
|
|
final RegExp _straySlashes = RegExp(r'(^/+)|(/+$)');
|
2018-11-02 04:22:32 +00:00
|
|
|
final MediaType _fallbackMediaType = MediaType('application', 'octet-stream');
|
2016-11-23 22:03:06 +00:00
|
|
|
|
2019-10-12 15:19:57 +00:00
|
|
|
/// A middleware class that forwards requests (reverse proxies) to an upstream server.
|
|
|
|
///
|
|
|
|
/// Supports WebSockets, in addition to regular HTTP requests.
|
2017-09-24 18:49:18 +00:00
|
|
|
class Proxy {
|
2021-06-10 08:47:05 +00:00
|
|
|
String? _prefix;
|
2017-04-26 12:20:14 +00:00
|
|
|
|
2021-07-10 04:32:42 +00:00
|
|
|
final Context _p = Context(style: Style.url);
|
|
|
|
|
2019-10-12 15:19:57 +00:00
|
|
|
/// The underlying [Client] to use.
|
|
|
|
final http.Client httpClient;
|
2017-09-24 18:49:18 +00:00
|
|
|
|
2017-04-26 12:20:14 +00:00
|
|
|
/// If `true` (default), then the plug-in will ignore failures to connect to the proxy, and allow other handlers to run.
|
|
|
|
final bool recoverFromDead;
|
2017-09-24 18:49:18 +00:00
|
|
|
final bool recoverFrom404;
|
2018-11-09 00:14:32 +00:00
|
|
|
final Uri baseUrl;
|
|
|
|
final String publicPath;
|
2018-11-02 04:22:32 +00:00
|
|
|
|
|
|
|
/// If `null` then no timout is added for requests
|
2021-06-10 08:47:05 +00:00
|
|
|
final Duration? timeout;
|
2017-06-20 19:31:35 +00:00
|
|
|
|
2017-09-24 18:49:18 +00:00
|
|
|
Proxy(
|
2019-10-12 15:19:57 +00:00
|
|
|
baseUrl, {
|
2021-06-10 08:47:05 +00:00
|
|
|
http.Client? httpClient,
|
2019-05-02 23:19:30 +00:00
|
|
|
this.publicPath = '/',
|
|
|
|
this.recoverFromDead = true,
|
|
|
|
this.recoverFrom404 = true,
|
2017-06-20 19:31:35 +00:00
|
|
|
this.timeout,
|
2021-06-10 08:47:05 +00:00
|
|
|
}) : baseUrl = baseUrl is Uri ? baseUrl : Uri.parse(baseUrl.toString()),
|
|
|
|
httpClient = httpClient ?? http.Client() {
|
2019-10-12 15:19:57 +00:00
|
|
|
if (!this.baseUrl.hasScheme || !this.baseUrl.hasAuthority) {
|
2019-05-02 23:19:30 +00:00
|
|
|
throw ArgumentError(
|
2018-11-09 00:27:50 +00:00
|
|
|
'Invalid `baseUrl`. URI must have both a scheme and authority.');
|
2019-10-12 15:19:57 +00:00
|
|
|
}
|
2021-06-10 08:47:05 +00:00
|
|
|
/*
|
|
|
|
if (recoverFromDead == null) {
|
|
|
|
throw ArgumentError.notNull('recoverFromDead');
|
2019-10-12 15:19:57 +00:00
|
|
|
}
|
2021-06-10 08:47:05 +00:00
|
|
|
if (recoverFrom404 == null) {
|
|
|
|
throw ArgumentError.notNull('recoverFrom404');
|
2019-10-12 15:19:57 +00:00
|
|
|
}
|
2021-06-10 08:47:05 +00:00
|
|
|
*/
|
2018-11-02 04:22:32 +00:00
|
|
|
|
2021-06-10 08:47:05 +00:00
|
|
|
_prefix = publicPath.replaceAll(_straySlashes, '');
|
2016-11-23 22:03:06 +00:00
|
|
|
}
|
|
|
|
|
2017-09-24 18:49:18 +00:00
|
|
|
void close() => httpClient.close();
|
2016-11-23 22:03:06 +00:00
|
|
|
|
2019-10-12 15:19:57 +00:00
|
|
|
/// 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.
|
2021-06-10 08:47:05 +00:00
|
|
|
RequestHandler pushState(String path, {Iterable? accepts}) {
|
2019-10-12 15:19:57 +00:00
|
|
|
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<bool>.value(true);
|
|
|
|
|
|
|
|
if (accepts?.isNotEmpty == true) {
|
2021-06-10 08:47:05 +00:00
|
|
|
if (!accepts!.any((x) => req.accepts(x, strict: true))) {
|
2019-10-12 15:19:57 +00:00
|
|
|
return Future<bool>.value(true);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
return servePath(vPath, req, res);
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
2017-09-24 18:49:18 +00:00
|
|
|
/// Handles an incoming HTTP request.
|
|
|
|
Future<bool> handleRequest(RequestContext req, ResponseContext res) {
|
|
|
|
var path = req.path.replaceAll(_straySlashes, '');
|
2016-11-23 22:03:06 +00:00
|
|
|
|
2021-07-10 04:32:42 +00:00
|
|
|
if (_prefix?.isNotEmpty == true) {
|
|
|
|
if (!_p.isWithin(_prefix!, path) && !_p.equals(_prefix!, path)) {
|
2019-05-02 23:19:30 +00:00
|
|
|
return Future<bool>.value(true);
|
2018-11-09 00:14:32 +00:00
|
|
|
}
|
2018-11-02 04:22:32 +00:00
|
|
|
|
2021-07-10 04:32:42 +00:00
|
|
|
path = _p.relative(path, from: _prefix);
|
2016-11-23 22:03:06 +00:00
|
|
|
}
|
|
|
|
|
2017-09-24 18:49:18 +00:00
|
|
|
return servePath(path, req, res);
|
2016-11-23 22:03:06 +00:00
|
|
|
}
|
|
|
|
|
2017-09-24 18:49:18 +00:00
|
|
|
/// Proxies a request to the given path on the remote server.
|
2018-11-08 22:13:26 +00:00
|
|
|
Future<bool> servePath(
|
|
|
|
String path, RequestContext req, ResponseContext res) async {
|
2017-09-24 18:49:18 +00:00
|
|
|
http.StreamedResponse rs;
|
2016-11-23 22:03:06 +00:00
|
|
|
|
2021-07-10 04:32:42 +00:00
|
|
|
var uri = baseUrl.replace(path: _p.join(baseUrl.path, path));
|
2017-04-26 12:20:14 +00:00
|
|
|
|
2018-11-09 00:14:32 +00:00
|
|
|
try {
|
|
|
|
if (req is HttpRequestContext &&
|
2021-06-10 08:47:05 +00:00
|
|
|
WebSocketTransformer.isUpgradeRequest(req.rawRequest!)) {
|
2018-11-09 00:14:32 +00:00
|
|
|
res.detach();
|
|
|
|
uri = uri.replace(scheme: uri.scheme == 'https' ? 'wss' : 'ws');
|
|
|
|
|
|
|
|
try {
|
2021-06-10 08:47:05 +00:00
|
|
|
var local = await WebSocketTransformer.upgrade(req.rawRequest!);
|
2018-11-09 00:14:32 +00:00
|
|
|
var remote = await WebSocket.connect(uri.toString());
|
|
|
|
|
2019-05-02 23:19:30 +00:00
|
|
|
scheduleMicrotask(() => local.pipe(remote));
|
|
|
|
scheduleMicrotask(() => remote.pipe(local));
|
2018-11-09 00:14:32 +00:00
|
|
|
return false;
|
|
|
|
} catch (e, st) {
|
2022-04-25 00:54:13 +00:00
|
|
|
throw AngelHttpException(
|
2018-11-09 00:14:32 +00:00
|
|
|
message: 'Could not connect WebSocket', stackTrace: st);
|
|
|
|
}
|
|
|
|
}
|
2017-09-24 18:49:18 +00:00
|
|
|
|
2018-11-09 00:14:32 +00:00
|
|
|
Future<http.StreamedResponse> accessRemote() async {
|
2017-09-24 18:49:18 +00:00
|
|
|
var headers = <String, String>{
|
2018-11-09 00:14:32 +00:00
|
|
|
'host': uri.authority,
|
2018-03-20 16:38:22 +00:00
|
|
|
'x-forwarded-for': req.remoteAddress.address,
|
2021-07-10 04:32:42 +00:00
|
|
|
'x-forwarded-port': req.uri?.port.toString() ?? '',
|
2018-11-08 22:13:26 +00:00
|
|
|
'x-forwarded-host':
|
2021-07-10 04:32:42 +00:00
|
|
|
req.headers?.host ?? req.headers?.value('host') ?? 'none',
|
2018-11-09 00:14:32 +00:00
|
|
|
'x-forwarded-proto': uri.scheme,
|
2017-09-24 18:49:18 +00:00
|
|
|
};
|
|
|
|
|
2021-07-10 04:32:42 +00:00
|
|
|
req.headers?.forEach((name, values) {
|
2017-09-24 18:49:18 +00:00
|
|
|
headers[name] = values.join(',');
|
|
|
|
});
|
|
|
|
|
2018-11-08 22:13:26 +00:00
|
|
|
headers[HttpHeaders.cookieHeader] =
|
|
|
|
req.cookies.map<String>((c) => '${c.name}=${c.value}').join('; ');
|
2017-09-24 18:49:18 +00:00
|
|
|
|
2021-06-10 08:47:05 +00:00
|
|
|
List<int>? body;
|
2017-09-24 18:49:18 +00:00
|
|
|
|
2018-12-09 22:39:11 +00:00
|
|
|
if (!req.hasParsedBody) {
|
2021-07-10 04:32:42 +00:00
|
|
|
body = await req.body
|
|
|
|
?.fold<BytesBuilder>(BytesBuilder(), (bb, buf) => bb..add(buf))
|
2018-12-09 22:39:11 +00:00
|
|
|
.then((bb) => bb.takeBytes());
|
2017-06-20 19:31:35 +00:00
|
|
|
}
|
|
|
|
|
2019-05-02 23:19:30 +00:00
|
|
|
var rq = http.Request(req.method, uri);
|
2017-09-24 18:49:18 +00:00
|
|
|
rq.headers.addAll(headers);
|
|
|
|
rq.headers['host'] = rq.url.host;
|
2019-05-02 23:19:30 +00:00
|
|
|
rq.encoding = Utf8Codec(allowMalformed: true);
|
2017-09-24 18:49:18 +00:00
|
|
|
|
2021-07-10 04:32:42 +00:00
|
|
|
if (body != null) {
|
|
|
|
rq.bodyBytes = body;
|
|
|
|
}
|
2017-09-24 18:49:18 +00:00
|
|
|
|
2018-03-20 16:38:22 +00:00
|
|
|
return httpClient.send(rq);
|
2017-04-26 12:20:14 +00:00
|
|
|
}
|
|
|
|
|
2017-06-20 19:31:35 +00:00
|
|
|
var future = accessRemote();
|
2021-07-10 04:32:42 +00:00
|
|
|
if (timeout != null) {
|
|
|
|
future = future.timeout(timeout!);
|
|
|
|
}
|
2017-06-20 19:31:35 +00:00
|
|
|
rs = await future;
|
|
|
|
} on TimeoutException catch (e, st) {
|
2018-11-02 04:22:32 +00:00
|
|
|
if (recoverFromDead) return true;
|
|
|
|
|
2019-05-02 23:19:30 +00:00
|
|
|
throw AngelHttpException(
|
2018-11-02 04:22:32 +00:00
|
|
|
stackTrace: st,
|
|
|
|
statusCode: 504,
|
2018-11-08 22:13:26 +00:00
|
|
|
message:
|
2021-06-10 08:47:05 +00:00
|
|
|
'Connection to remote host "$uri" timed out after ${timeout!.inMilliseconds}ms.',
|
2018-11-02 04:22:32 +00:00
|
|
|
);
|
|
|
|
} catch (e) {
|
2018-11-09 00:14:32 +00:00
|
|
|
if (recoverFromDead && e is! AngelHttpException) return true;
|
2018-11-02 04:22:32 +00:00
|
|
|
rethrow;
|
|
|
|
}
|
|
|
|
|
|
|
|
if (rs.statusCode == 404 && recoverFrom404) return true;
|
|
|
|
if (rs.contentLength == 0 && recoverFromDead) return true;
|
|
|
|
|
|
|
|
MediaType mediaType;
|
|
|
|
if (rs.headers.containsKey(HttpHeaders.contentTypeHeader)) {
|
|
|
|
try {
|
2021-06-10 08:47:05 +00:00
|
|
|
mediaType = MediaType.parse(rs.headers[HttpHeaders.contentTypeHeader]!);
|
2018-11-02 04:22:32 +00:00
|
|
|
} on FormatException catch (e, st) {
|
|
|
|
if (recoverFromDead) return true;
|
|
|
|
|
2019-05-02 23:19:30 +00:00
|
|
|
throw AngelHttpException(
|
2017-09-24 18:49:18 +00:00
|
|
|
stackTrace: st,
|
|
|
|
statusCode: 504,
|
2018-11-09 00:14:32 +00:00
|
|
|
message: 'Host "$uri" returned a malformed content-type',
|
2017-09-24 18:49:18 +00:00
|
|
|
);
|
2018-11-02 04:22:32 +00:00
|
|
|
}
|
|
|
|
} else {
|
|
|
|
mediaType = _fallbackMediaType;
|
2017-04-02 02:06:27 +00:00
|
|
|
}
|
|
|
|
|
2018-11-03 01:10:47 +00:00
|
|
|
/// if [http.Client] does not provide us with a content length
|
|
|
|
/// OR [http.Client] is about to decode the response (bytecount returned by [http.Response].stream != known length)
|
|
|
|
/// then we can not provide a value downstream => set to '-1' for 'unspecified length'
|
|
|
|
var isContentLengthUnknown = rs.contentLength == null ||
|
|
|
|
rs.headers[HttpHeaders.contentEncodingHeader]?.isNotEmpty == true ||
|
|
|
|
rs.headers[HttpHeaders.transferEncodingHeader]?.isNotEmpty == true;
|
|
|
|
|
2019-05-02 23:19:30 +00:00
|
|
|
var proxiedHeaders = Map<String, String>.from(rs.headers)
|
2018-11-08 22:13:26 +00:00
|
|
|
..remove(
|
|
|
|
HttpHeaders.contentEncodingHeader) // drop, http.Client has decoded
|
|
|
|
..remove(
|
|
|
|
HttpHeaders.transferEncodingHeader) // drop, http.Client has decoded
|
|
|
|
..[HttpHeaders.contentLengthHeader] =
|
|
|
|
"${isContentLengthUnknown ? '-1' : rs.contentLength}";
|
2017-04-24 20:46:52 +00:00
|
|
|
|
2017-04-02 02:06:27 +00:00
|
|
|
res
|
2018-11-02 04:22:32 +00:00
|
|
|
..contentType = mediaType
|
2017-04-02 02:06:27 +00:00
|
|
|
..statusCode = rs.statusCode
|
2018-11-02 04:22:32 +00:00
|
|
|
..headers.addAll(proxiedHeaders);
|
2017-04-22 18:46:00 +00:00
|
|
|
|
2018-11-03 01:10:47 +00:00
|
|
|
await rs.stream.pipe(res);
|
2017-04-22 18:46:00 +00:00
|
|
|
|
2017-09-24 18:49:18 +00:00
|
|
|
return false;
|
2016-11-23 22:03:06 +00:00
|
|
|
}
|
|
|
|
}
|