blob: f1acc9f4c19e0abf42809c780126107fc0fa057a [file] [log] [blame]
// Copyright (c) 2014, the Dart project authors. Please see the AUTHORS file
// for details. All rights reserved. Use of this source code is governed by a
// BSD-style license that can be found in the LICENSE file.
import 'dart:convert';
import 'package:shelf/shelf.dart';
import 'package:web_socket_channel/web_socket_channel.dart';
/// A class that exposes a handler for upgrading WebSocket requests.
class WebSocketHandler {
/// The function to call when a request is upgraded.
final Function _onConnection;
/// The set of protocols the user supports, or `null`.
final Set<String> _protocols;
/// The set of allowed browser origin connections, or `null`..
final Set<String> _allowedOrigins;
WebSocketHandler(this._onConnection, this._protocols, this._allowedOrigins);
/// The [Handler].
Response handle(Request request) {
if (request.method != 'GET') return _notFound();
var connection = request.headers['Connection'];
if (connection == null) return _notFound();
var tokens =
connection.toLowerCase().split(',').map((token) => token.trim());
if (!tokens.contains('upgrade')) return _notFound();
var upgrade = request.headers['Upgrade'];
if (upgrade == null) return _notFound();
if (upgrade.toLowerCase() != 'websocket') return _notFound();
var version = request.headers['Sec-WebSocket-Version'];
if (version == null) {
return _badRequest('missing Sec-WebSocket-Version header.');
} else if (version != '13') {
return _notFound();
}
if (request.protocolVersion != '1.1') {
return _badRequest('unexpected HTTP version '
'"${request.protocolVersion}".');
}
var key = request.headers['Sec-WebSocket-Key'];
if (key == null) return _badRequest('missing Sec-WebSocket-Key header.');
if (!request.canHijack) {
throw new ArgumentError("webSocketHandler may only be used with a server "
"that supports request hijacking.");
}
// The Origin header is always set by browser connections. By filtering out
// unexpected origins, we ensure that malicious JavaScript is unable to fake
// a WebSocket handshake.
var origin = request.headers['Origin'];
if (origin != null &&
_allowedOrigins != null &&
!_allowedOrigins.contains(origin.toLowerCase())) {
return _forbidden('invalid origin "$origin".');
}
var protocol = _chooseProtocol(request);
request.hijack((channel) {
var sink = utf8.encoder.startChunkedConversion(channel.sink);
sink.add("HTTP/1.1 101 Switching Protocols\r\n"
"Upgrade: websocket\r\n"
"Connection: Upgrade\r\n"
"Sec-WebSocket-Accept: ${WebSocketChannel.signKey(key)}\r\n");
if (protocol != null) sink.add("Sec-WebSocket-Protocol: $protocol\r\n");
sink.add("\r\n");
_onConnection(new WebSocketChannel(channel), protocol);
});
// [request.hijack] is guaranteed to throw a [HijackException], so we'll
// never get here.
assert(false);
return null;
}
/// Selects a subprotocol to use for the given connection.
///
/// If no matching protocol can be found, returns `null`.
String _chooseProtocol(Request request) {
var protocols = request.headers['Sec-WebSocket-Protocol'];
if (protocols == null) return null;
for (var protocol in protocols.split(',')) {
protocol = protocol.trim();
if (_protocols.contains(protocol)) return protocol;
}
return null;
}
/// Returns a 404 Not Found response.
Response _notFound() => _htmlResponse(
404, "404 Not Found", "Only WebSocket connections are supported.");
/// Returns a 400 Bad Request response.
///
/// [message] will be HTML-escaped before being included in the response body.
Response _badRequest(String message) => _htmlResponse(
400, "400 Bad Request", "Invalid WebSocket upgrade request: $message");
/// Returns a 403 Forbidden response.
///
/// [message] will be HTML-escaped before being included in the response body.
Response _forbidden(String message) => _htmlResponse(
403, "403 Forbidden", "WebSocket upgrade refused: $message");
/// Creates an HTTP response with the given [statusCode] and an HTML body with
/// [title] and [message].
///
/// [title] and [message] will be automatically HTML-escaped.
Response _htmlResponse(int statusCode, String title, String message) {
title = htmlEscape.convert(title);
message = htmlEscape.convert(message);
return new Response(statusCode, body: """
<!doctype html>
<html>
<head><title>$title</title></head>
<body>
<h1>$title</h1>
<p>$message</p>
</body>
</html>
""", headers: {'content-type': 'text/html'});
}
}