| // Copyright (c) 2021, 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:async'; | |
| import 'dart:convert'; | |
| import 'dart:io'; | |
| import 'dart:typed_data'; | |
| import 'package:devtools_shared/devtools_deeplink_io.dart'; | |
| import 'package:devtools_shared/devtools_extensions.dart'; | |
| import 'package:devtools_shared/devtools_extensions_io.dart'; | |
| import 'package:devtools_shared/devtools_server.dart'; | |
| import 'package:devtools_shared/devtools_shared.dart'; | |
| import 'package:mime/mime.dart'; | |
| import 'package:path/path.dart' as path; | |
| import 'package:shelf/shelf.dart'; | |
| import 'package:shelf_static/shelf_static.dart'; | |
| import 'package:sse/server/sse_handler.dart'; | |
| import '../constants.dart'; | |
| import '../dds_impl.dart'; | |
| import '../utils/validation.dart' as validation; | |
| import 'client.dart'; | |
| import 'utils.dart'; | |
| /// Returns a [Handler] which handles serving DevTools and the DevTools server | |
| /// API. | |
| /// | |
| /// [buildDir] is the path to the pre-compiled DevTools instance to be served. | |
| /// | |
| /// [notFoundHandler] is a [Handler] to which requests that could not be handled | |
| /// by the DevTools handler are forwarded (e.g., a proxy to the VM | |
| /// service). | |
| /// | |
| /// If [dds] is null, DevTools is not being served by a DDS instance and is | |
| /// served by a standalone server (see `package:dds/devtools_server.dart`). | |
| /// | |
| /// If [dtd] or [dtd.uri] is null, the Dart Tooling Daemon is not available for | |
| /// this DevTools server connection. | |
| /// | |
| /// If [dtd.uri] is non-null, but [dtd.secret] is null, then DTD was started by a | |
| /// client that is not the DevTools server (e.g. an IDE). | |
| FutureOr<Handler> defaultHandler({ | |
| required ExtensionsManager devtoolsExtensionsManager, | |
| String? buildDir, | |
| ClientManager? clientManager, | |
| DartDevelopmentServiceImpl? dds, | |
| bool disableServiceOriginCheck = false, | |
| DtdInfo? dtd, | |
| Handler? notFoundHandler, | |
| Uri? serverUri, | |
| }) { | |
| // When served through DDS, the app root is /devtools. | |
| // This variable is used in base href and must start and end with `/` | |
| var appRoot = dds != null ? '/devtools/' : '/'; | |
| if (dds?.authCodesEnabled ?? false) { | |
| appRoot = '/${dds!.authCode}$appRoot'; | |
| } | |
| /// A wrapper around [devtoolsStaticAssetHandler] that handles serving | |
| /// index.html up for / and non-file requests like /memory, /inspector, etc. | |
| /// with the correct base href for the DevTools root. | |
| FutureOr<Response> devtoolsAssetHandler(Request request) { | |
| // To avoid hard-coding a set of page names here (or needing access to one | |
| // from DevTools, assume any single-segment path with no extension is a | |
| // DevTools page that needs to serve up index.html). | |
| final pathSegments = request.url.pathSegments; | |
| final isExtensionRequest = pathSegments.safeGet(0) == extensionRequestPath; | |
| if (isExtensionRequest) { | |
| // This identifier should be the extension name appended with its version. | |
| final extensionIdentifier = pathSegments.safeGet(1); | |
| if (extensionIdentifier != null) { | |
| final extensionAssetsLocation = | |
| devtoolsExtensionsManager.lookupLocationFor(extensionIdentifier); | |
| if (extensionAssetsLocation != null) { | |
| // Remove the first two elements (devtools_extensions/foo_1.0.0) to | |
| // get the relative path to the extension asset. | |
| final relativePathToExtensionAsset = | |
| path.joinAll(pathSegments.sublist(2)); | |
| final assetPath = path.normalize( | |
| path.join(extensionAssetsLocation, relativePathToExtensionAsset), | |
| ); | |
| // Ensure the normalized path is still within the expected | |
| // [extensionAssetsLocation] to protect against directory traversal. | |
| if (path.isWithin(extensionAssetsLocation, assetPath)) { | |
| final contentType = lookupMimeType(assetPath) ?? 'text/html'; | |
| final baseHref = | |
| '$appRoot$extensionRequestPath/$extensionIdentifier/'; | |
| return _serveStaticFile( | |
| request, | |
| File(assetPath), | |
| contentType, | |
| baseHref: baseHref, | |
| ); | |
| } | |
| } | |
| } | |
| } | |
| if (buildDir == null) { | |
| return Response.notFound('No build directory was specified.'); | |
| } | |
| const defaultDocument = 'index.html'; | |
| final indexFile = File(path.join(buildDir, defaultDocument)); | |
| final isValidRootPage = pathSegments.isEmpty || | |
| (pathSegments.length == 1 && !pathSegments[0].contains('.')); | |
| if (isValidRootPage) { | |
| return _serveStaticFile( | |
| request, | |
| indexFile, | |
| 'text/html', | |
| baseHref: appRoot, | |
| ); | |
| } | |
| // Serves the static web assets for DevTools. | |
| final devtoolsStaticAssetHandler = createStaticHandler( | |
| buildDir, | |
| defaultDocument: defaultDocument, | |
| ); | |
| return devtoolsStaticAssetHandler(request); | |
| } | |
| // Support DevTools client-server interface via SSE. | |
| // Note: the handler path needs to match the full *original* path, not the | |
| // current request URL (we remove '/devtools' in the initial router but we | |
| // need to include it here). | |
| final devToolsSseHandlerPath = '${appRoot}api/sse'; | |
| final devToolsApiHandler = SseHandler( | |
| Uri.parse(devToolsSseHandlerPath), | |
| keepAlive: sseKeepAlive, | |
| ); | |
| clientManager ??= ClientManager(requestNotificationPermissions: false); | |
| devToolsApiHandler.connections.rest.listen( | |
| (sseConnection) => clientManager!.acceptClient( | |
| sseConnection, | |
| enableLogging: dds?.shouldLogRequests ?? false, | |
| ), | |
| ); | |
| bool isAllowedOrigin(String origin) { | |
| return validation.isAllowedOrigin( | |
| origin, | |
| allowedUris: [ | |
| if (dds?.uri case final uri?) uri, | |
| if (serverUri case final uri?) uri, | |
| ], | |
| ); | |
| } | |
| bool isAllowedHost(String hostHeader) { | |
| return validation.isAllowedHost( | |
| hostHeader, | |
| allowedUris: [ | |
| if (dds?.uri case final uri?) uri, | |
| if (serverUri case final uri?) uri, | |
| ], | |
| ); | |
| } | |
| FutureOr<Response> devtoolsHandler(Request request) { | |
| final originCheckEnabled = !(dds != null | |
| ? dds.disableServiceOriginCheck | |
| : disableServiceOriginCheck); | |
| // If the request isn't of the form api/<method> assume it's a request for | |
| // DevTools assets. | |
| final pathSegments = request.url.pathSegments; | |
| if (pathSegments.length < 2 || pathSegments.first != 'api') { | |
| return devtoolsAssetHandler(request); | |
| } | |
| if (originCheckEnabled) { | |
| // Host header check for all api/* requests to prevent DNS rebinding. | |
| final hostHeader = request.headers[HttpHeaders.hostHeader]; | |
| if (hostHeader == null || !isAllowedHost(hostHeader)) { | |
| return Response.forbidden('forbidden host'); | |
| } | |
| // Origin header check for all api/* requests to prevent CSRF. Browsers | |
| // set Origin on cross-origin fetch/XHR/EventSource requests, so this | |
| // rejects requests from a page the DevTools server never granted trust | |
| // to, even though such a request's Host header legitimately matches | |
| // this server (Host reflects the request's destination, not the page | |
| // that issued it, so the check above does not protect against this). | |
| final origin = request.headers['Origin']; | |
| if (origin != null && !isAllowedOrigin(origin)) { | |
| return Response.forbidden('forbidden origin'); | |
| } | |
| } | |
| final method = request.url.pathSegments[1]; | |
| if (method == 'sse') { | |
| return devToolsApiHandler.handler(request); | |
| } | |
| if (method == 'ping') { | |
| // Note: we have an 'OK' body response, otherwise the response has an | |
| // incorrect status code (204 instead of 200). | |
| return Response.ok('OK'); | |
| } | |
| if (!ServerApi.canHandle(request)) { | |
| return Response.notFound('$method is not a valid API'); | |
| } | |
| return ServerApi.handle( | |
| request, | |
| extensionsManager: devtoolsExtensionsManager, | |
| deeplinkManager: DeeplinkManager(), | |
| dtd: dtd, | |
| ); | |
| } | |
| return (Request request) { | |
| if (notFoundHandler != null) { | |
| final pathSegments = request.url.pathSegments; | |
| if (pathSegments.isEmpty || pathSegments.first != 'devtools') { | |
| return notFoundHandler(request); | |
| } | |
| // Forward all requests to /devtools/* to the DevTools handler. | |
| request = request.change(path: 'devtools'); | |
| } | |
| return devtoolsHandler(request); | |
| }; | |
| } | |
| /// Serves [file] for all requests. | |
| /// | |
| /// For files with [contentType] 'text/html' and a provided [baseHref] value, | |
| /// any existing `<base href="">` tag will be rewritten with the provided path. | |
| Future<Response> _serveStaticFile( | |
| Request request, | |
| File file, | |
| String contentType, { | |
| String? baseHref, | |
| }) async { | |
| final headers = {HttpHeaders.contentTypeHeader: contentType}; | |
| if (contentType != 'text/html') { | |
| late final Uint8List fileBytes; | |
| try { | |
| fileBytes = file.readAsBytesSync(); | |
| } on PathNotFoundException catch (_) { | |
| // Wait a short delay, and then retry in case we have hit a race condition | |
| // between a static file being served and accessed. See | |
| // https://github.com/flutter/devtools/issues/6365. | |
| await Future.delayed(Duration(milliseconds: 500)); | |
| try { | |
| fileBytes = file.readAsBytesSync(); | |
| } catch (e) { | |
| return Response.notFound( | |
| 'could not read file as bytes: ${request.url.path}', | |
| ); | |
| } | |
| } | |
| return Response.ok(fileBytes, headers: headers); | |
| } | |
| late String contents; | |
| try { | |
| contents = file.readAsStringSync(); | |
| } catch (e) { | |
| return Response.notFound( | |
| 'could not read file as String: ${request.url.path}', | |
| ); | |
| } | |
| if (baseHref != null) { | |
| assert(baseHref.startsWith('/')); | |
| assert(baseHref.endsWith('/')); | |
| // Always use a relative base href to support going through proxies that | |
| // rewrite paths. For example if the server thinks we are hosting at | |
| // `http://localhost/devtools/` but a frontend proxy means the client app | |
| // is at `http://localhost/proxy/1234/devtools/` then we need the base href | |
| // to effectively be `/proxy/1234/devtools/`, however we have no knowledge | |
| // of `/proxy/1234` because it was stripped by the proxy on its way to us. | |
| baseHref = computeRelativeBaseHref(baseHref, request.requestedUri); | |
| // Replace the base href to match where the app is being served from. | |
| final baseHrefPattern = RegExp(r'<base href="\/"\s?\/?>'); | |
| contents = contents.replaceFirst( | |
| baseHrefPattern, | |
| '<base href="${htmlEscape.convert(baseHref)}">', | |
| ); | |
| } | |
| return Response.ok(contents, headers: headers); | |
| } | |
| /// Computes a relative "base href" that can be used in place of | |
| /// [absoluteBaseHref] for a request served at [requestUri]. | |
| String computeRelativeBaseHref(String absoluteBaseHref, Uri requestUri) { | |
| // path.relative will always treat `from` as if it's a directory, but for | |
| // URIs that's not correct. If the request is /foo/bar then `.` is `/foo` and | |
| // not `/foo/bar`. To handle this, trim the last segment if the request does | |
| // not end with a slash. | |
| final requestFolderPath = requestUri.path.endsWith('/') | |
| ? requestUri.path | |
| : path.posix.dirname(requestUri.path); | |
| return path.posix.relative(absoluteBaseHref, from: requestFolderPath); | |
| } |