From 52edff436ce59b329a702ef55e89f121fe8bf4a3 Mon Sep 17 00:00:00 2001 From: Tien Do Nam Date: Tue, 20 Aug 2024 02:16:15 +0200 Subject: [PATCH] feat: use HttpServer instead of shelf --- app/lib/model/state/server/server_state.dart | 5 +- .../state/server/server_state.mapper.dart | 8 +- app/lib/provider/network/send_provider.dart | 21 +- .../network/server/controller/common.dart | 29 +-- .../server/controller/receive_controller.dart | 180 +++++++++--------- .../server/controller/send_controller.dart | 94 +++++---- .../network/server/server_provider.dart | 75 +++----- .../provider/network/server/server_utils.dart | 58 ------ app/lib/util/simple_server.dart | 141 ++++++++++++++ app/lib/util/stream.dart | 21 ++ app/pubspec.lock | 18 +- app/pubspec.yaml | 2 - 12 files changed, 349 insertions(+), 303 deletions(-) create mode 100644 app/lib/util/simple_server.dart create mode 100644 app/lib/util/stream.dart diff --git a/app/lib/model/state/server/server_state.dart b/app/lib/model/state/server/server_state.dart index eadeca04..f0624e5e 100644 --- a/app/lib/model/state/server/server_state.dart +++ b/app/lib/model/state/server/server_state.dart @@ -1,14 +1,13 @@ -import 'dart:io'; - import 'package:dart_mappable/dart_mappable.dart'; import 'package:localsend_app/model/state/send/web/web_send_state.dart'; import 'package:localsend_app/model/state/server/receive_session_state.dart'; +import 'package:localsend_app/util/simple_server.dart'; part 'server_state.mapper.dart'; @MappableClass() class ServerState with ServerStateMappable { - final HttpServer httpServer; + final SimpleServer httpServer; final String alias; final int port; final bool https; diff --git a/app/lib/model/state/server/server_state.mapper.dart b/app/lib/model/state/server/server_state.mapper.dart index 775293a8..4d44dc78 100644 --- a/app/lib/model/state/server/server_state.mapper.dart +++ b/app/lib/model/state/server/server_state.mapper.dart @@ -22,8 +22,8 @@ class ServerStateMapper extends ClassMapperBase { @override final String id = 'ServerState'; - static HttpServer _$httpServer(ServerState v) => v.httpServer; - static const Field _f$httpServer = Field('httpServer', _$httpServer); + static SimpleServer _$httpServer(ServerState v) => v.httpServer; + static const Field _f$httpServer = Field('httpServer', _$httpServer); static String _$alias(ServerState v) => v.alias; static const Field _f$alias = Field('alias', _$alias); static int _$port(ServerState v) => v.port; @@ -106,7 +106,7 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> implements WebSendStateCopyWith<$R, WebSendState, WebSendState>? get webSendState; MapCopyWith<$R, String, int, ObjectCopyWith<$R, int, int>> get pinAttempts; $R call( - {HttpServer? httpServer, + {SimpleServer? httpServer, String? alias, int? port, bool? https, @@ -130,7 +130,7 @@ class _ServerStateCopyWithImpl<$R, $Out> extends ClassCopyWithBase<$R, ServerSta MapCopyWith($value.pinAttempts, (v, t) => ObjectCopyWith(v, $identity, t), (v) => call(pinAttempts: v)); @override $R call( - {HttpServer? httpServer, + {SimpleServer? httpServer, String? alias, int? port, bool? https, diff --git a/app/lib/provider/network/send_provider.dart b/app/lib/provider/network/send_provider.dart index db69dedd..e44f6fa7 100644 --- a/app/lib/provider/network/send_provider.dart +++ b/app/lib/provider/network/send_provider.dart @@ -27,6 +27,7 @@ import 'package:localsend_app/provider/dio_provider.dart'; import 'package:localsend_app/provider/progress_provider.dart'; import 'package:localsend_app/provider/selection/selected_sending_files_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; +import 'package:localsend_app/util/stream.dart'; import 'package:localsend_app/widget/dialogs/pin_dialog.dart'; import 'package:logging/logging.dart'; import 'package:refena_flutter/refena_flutter.dart'; @@ -415,25 +416,7 @@ class SendNotifier extends Notifier> { : File(file.path!).openRead() : null; - final StreamController>? streamController; - StreamSubscription>? subscription; - if (fileStream != null) { - streamController = StreamController>( - onListen: () => subscription!.resume(), - onPause: () => subscription!.pause(), - onResume: () => subscription!.resume(), - onCancel: () => subscription!.cancel(), - ); - - subscription = fileStream.listen( - (data) => streamController!.add(data), - onError: (e, st) => streamController!.addError(e, st), - onDone: () => streamController!.close(), - ); - } else { - streamController = null; - subscription = null; - } + final (streamController, subscription) = fileStream?.digested() ?? (null, null); String? fileError; try { diff --git a/app/lib/provider/network/server/controller/common.dart b/app/lib/provider/network/server/controller/common.dart index 7be0da9a..55edefa5 100644 --- a/app/lib/provider/network/server/controller/common.dart +++ b/app/lib/provider/network/server/controller/common.dart @@ -1,33 +1,38 @@ -import 'package:localsend_app/provider/network/server/server_utils.dart'; -import 'package:shelf/shelf.dart'; +import 'dart:io'; -/// Returns 401 or 429 if the pin is invalid or too many attempts. -/// Returns null if the pin is correct, or if no pin is set. -Response? handlePin({ +import 'package:localsend_app/provider/network/server/server_utils.dart'; +import 'package:localsend_app/util/simple_server.dart'; + +/// Responds with 401 or 429 if the pin is invalid or too many attempts. +/// Returns true if the pin is correct, or if no pin is set. +Future checkPin({ required ServerUtils server, required String? pin, required Map pinAttempts, - required Request request, -}) { + required HttpRequest request, +}) async { if (pin != null) { final attempts = pinAttempts[request.ip] ?? 0; if (attempts >= 3) { - return server.responseJson(429, message: 'Too many attempts.'); + await request.respondJson(429, message: 'Too many attempts.'); + return false; } - final requestPin = request.url.queryParameters['pin']; + final requestPin = request.uri.queryParameters['pin']; if (requestPin != pin) { if (requestPin?.isNotEmpty ?? false) { pinAttempts[request.ip] = attempts + 1; if (attempts == 2) { // it was 2 before incrementing - return server.responseJson(429, message: 'Too many attempts.'); + await request.respondJson(429, message: 'Too many attempts.'); + return false; } } - return server.responseJson(401, message: 'Invalid pin.'); + await request.respondJson(401, message: 'Invalid pin.'); + return false; } } - return null; + return true; } diff --git a/app/lib/provider/network/server/controller/receive_controller.dart b/app/lib/provider/network/server/controller/receive_controller.dart index aed4a1bf..a559f68e 100644 --- a/app/lib/provider/network/server/controller/receive_controller.dart +++ b/app/lib/provider/network/server/controller/receive_controller.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:convert'; +import 'dart:io'; import 'package:collection/collection.dart'; import 'package:common/api_route_builder.dart'; @@ -38,11 +39,10 @@ import 'package:localsend_app/util/native/directories.dart'; import 'package:localsend_app/util/native/file_saver.dart'; import 'package:localsend_app/util/native/platform_check.dart'; import 'package:localsend_app/util/native/tray_helper.dart'; +import 'package:localsend_app/util/simple_server.dart'; import 'package:logging/logging.dart'; import 'package:permission_handler/permission_handler.dart'; import 'package:routerino/routerino.dart'; -import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; import 'package:uuid/uuid.dart'; import 'package:window_manager/window_manager.dart'; @@ -58,72 +58,72 @@ class ReceiveController { /// Installs all routes for receiving files. void installRoutes({ - required Router router, + required SimpleServerRouteBuilder router, required String alias, required int port, required bool https, required String fingerprint, required String showToken, }) { - router.get(ApiRoute.info.v1, (Request request) { - return _infoHandler(request: request, alias: alias, fingerprint: fingerprint); + router.get(ApiRoute.info.v1, (HttpRequest request) async { + return await _infoHandler(request: request, alias: alias, fingerprint: fingerprint); }); - router.get(ApiRoute.info.v2, (Request request) { - return _infoHandler(request: request, alias: alias, fingerprint: fingerprint); + router.get(ApiRoute.info.v2, (HttpRequest request) async { + return await _infoHandler(request: request, alias: alias, fingerprint: fingerprint); }); // An upgraded version of /info - router.post(ApiRoute.register.v1, (Request request) async { - return _registerHandler(request: request, alias: alias, port: port, https: https, fingerprint: fingerprint); + router.post(ApiRoute.register.v1, (HttpRequest request) async { + return await _registerHandler(request: request, alias: alias, port: port, https: https, fingerprint: fingerprint); }); - router.post(ApiRoute.register.v2, (Request request) async { - return _registerHandler(request: request, alias: alias, port: port, https: https, fingerprint: fingerprint); + router.post(ApiRoute.register.v2, (HttpRequest request) async { + return await _registerHandler(request: request, alias: alias, port: port, https: https, fingerprint: fingerprint); }); - router.post(ApiRoute.prepareUpload.v1, (Request request) async { - return _prepareUploadHandler(request: request, port: port, https: https, v2: false); + router.post(ApiRoute.prepareUpload.v1, (HttpRequest request) async { + return await _prepareUploadHandler(request: request, port: port, https: https, v2: false); }); - router.post(ApiRoute.prepareUpload.v2, (Request request) async { - return _prepareUploadHandler(request: request, port: port, https: https, v2: true); + router.post(ApiRoute.prepareUpload.v2, (HttpRequest request) async { + return await _prepareUploadHandler(request: request, port: port, https: https, v2: true); }); - router.post(ApiRoute.upload.v1, (Request request) async { - return _uploadHandler(request: request, v2: false); + router.post(ApiRoute.upload.v1, (HttpRequest request) async { + return await _uploadHandler(request: request, v2: false); }); - router.post(ApiRoute.upload.v2, (Request request) async { - return _uploadHandler(request: request, v2: true); + router.post(ApiRoute.upload.v2, (HttpRequest request) async { + return await _uploadHandler(request: request, v2: true); }); - router.post(ApiRoute.cancel.v1, (Request request) { - return _cancelHandler(request: request, v2: false); + router.post(ApiRoute.cancel.v1, (HttpRequest request) async { + return await _cancelHandler(request: request, v2: false); }); - router.post(ApiRoute.cancel.v2, (Request request) { - return _cancelHandler(request: request, v2: true); + router.post(ApiRoute.cancel.v2, (HttpRequest request) async { + return await _cancelHandler(request: request, v2: true); }); - router.post(ApiRoute.show.v1, (Request request) async { - return _showHandler(request: request, showToken: showToken); + router.post(ApiRoute.show.v1, (HttpRequest request) async { + return await _showHandler(request: request, showToken: showToken); }); - router.post(ApiRoute.show.v2, (Request request) async { - return _showHandler(request: request, showToken: showToken); + router.post(ApiRoute.show.v2, (HttpRequest request) async { + return await _showHandler(request: request, showToken: showToken); }); } - Response _infoHandler({ - required Request request, + Future _infoHandler({ + required HttpRequest request, required String alias, required String fingerprint, - }) { - final senderFingerprint = request.url.queryParameters['fingerprint']; + }) async { + final senderFingerprint = request.uri.queryParameters['fingerprint']; if (senderFingerprint == fingerprint) { // "I talked to myself lol" - return server.responseJson(412, message: 'Self-discovered'); + return await request.respondJson(412, message: 'Self-discovered'); } final deviceInfo = server.ref.read(deviceInfoProvider); @@ -137,11 +137,11 @@ class ReceiveController { download: server.getState().webSendState != null, ); - return server.responseJson(200, body: dto.toJson()); + return await request.respondJson(200, body: dto.toJson()); } - Future _registerHandler({ - required Request request, + Future _registerHandler({ + required HttpRequest request, required String alias, required String fingerprint, required int port, @@ -152,12 +152,12 @@ class ReceiveController { try { requestDto = RegisterDto.fromJson(jsonDecode(payload)); } catch (e) { - return server.responseJson(400, message: 'Request body malformed'); + return await request.respondJson(400, message: 'Request body malformed'); } if (requestDto.fingerprint == fingerprint) { // "I talked to myself lol" - return server.responseJson(412, message: 'Self-discovered'); + return await request.respondJson(412, message: 'Self-discovered'); } // Save device information @@ -175,28 +175,28 @@ class ReceiveController { download: server.getState().webSendState != null, ); - return server.responseJson(200, body: responseDto.toJson()); + return await request.respondJson(200, body: responseDto.toJson()); } - Future _prepareUploadHandler({ - required Request request, + Future _prepareUploadHandler({ + required HttpRequest request, required int port, required bool https, required bool v2, }) async { if (server.getState().session != null) { // block incoming requests when we are already in a session - return server.responseJson(409, message: 'Blocked by another session'); + return await request.respondJson(409, message: 'Blocked by another session'); } - final pinResponse = handlePin( + final pinCorrect = await checkPin( server: server, pin: server.ref.read(settingsProvider).receivePin, pinAttempts: server.getState().pinAttempts, request: request, ); - if (pinResponse != null) { - return pinResponse; + if (!pinCorrect) { + return; } final PrepareUploadRequestDto dto; @@ -204,12 +204,13 @@ class ReceiveController { final payload = await request.readAsString(); dto = PrepareUploadRequestDto.fromJson(jsonDecode(payload)); } catch (e) { - return server.responseJson(400, message: 'Request body malformed'); + print(e); + return await request.respondJson(400, message: 'Request body malformed'); } if (dto.files.isEmpty) { // block empty requests (at least one file is required) - return server.responseJson(400, message: 'Request must contain at least one file'); + return await request.respondJson(400, message: 'Request must contain at least one file'); } final settings = server.ref.read(settingsProvider); @@ -292,19 +293,19 @@ class ReceiveController { if (server.getState().session == null) { // somehow this state is already disposed - return server.responseJson(500, message: 'Server is in invalid state'); + return await request.respondJson(500, message: 'Server is in invalid state'); } if (selection == null) { closeSession(); - return server.responseJson(403, message: 'File request declined by recipient'); + return await request.respondJson(403, message: 'File request declined by recipient'); } if (selection.isEmpty) { // nothing selected, send this to sender and close session // This usually happens for message transfers closeSession(); - return server.responseJson(204); + return await request.respondJson(204); } server.setState( @@ -367,55 +368,56 @@ class ReceiveController { } if (v2) { - return server.responseJson(200, + return await request.respondJson(200, body: PrepareUploadResponseDto( sessionId: sessionId, files: files.cast(), ).toJson()); } - return server.responseJson(200, body: files); + + return await request.respondJson(200, body: files); } - Future _uploadHandler({ - required Request request, + Future _uploadHandler({ + required HttpRequest request, required bool v2, }) async { final receiveState = server.getState().session; if (receiveState == null) { - return server.responseJson(409, message: 'No session'); + return await request.respondJson(409, message: 'No session'); } if (request.ip != receiveState.sender.ip) { _logger.warning('Invalid ip address: ${request.ip} (expected: ${receiveState.sender.ip})'); - return server.responseJson(403, message: 'Invalid IP address: ${request.ip}'); + return await request.respondJson(403, message: 'Invalid IP address: ${request.ip}'); } const allowedStates = {SessionStatus.sending, SessionStatus.finishedWithErrors}; if (!allowedStates.contains(receiveState.status)) { _logger.warning('Wrong state: ${receiveState.status}'); - return server.responseJson(409, message: 'Recipient is in wrong state'); + return await request.respondJson(409, message: 'Recipient is in wrong state'); } - final fileId = request.url.queryParameters['fileId']; - final token = request.url.queryParameters['token']; - final sessionId = request.url.queryParameters['sessionId']; + final fileId = request.uri.queryParameters['fileId']; + final token = request.uri.queryParameters['token']; + final sessionId = request.uri.queryParameters['sessionId']; if (fileId == null || token == null || (v2 && sessionId == null)) { // reject because of missing parameters _logger.warning('Missing parameters: fileId=$fileId, token=$token, sessionId=$sessionId'); - return server.responseJson(400, message: 'Missing parameters'); + return await request.respondJson(400, message: 'Missing parameters'); } if (v2 && sessionId != receiveState.sessionId) { // reject because of wrong session id _logger.warning('Wrong session id: $sessionId (expected: ${receiveState.sessionId})'); - return server.responseJson(403, message: 'Invalid session id'); + return await request.respondJson(403, message: 'Invalid session id'); } final receivingFile = receiveState.files[fileId]; if (receivingFile == null || receivingFile.token != token) { // reject because there is no file or token does not match _logger.warning('Wrong fileId: $fileId (expected: ${receivingFile?.file.id})'); - return server.responseJson(403, message: 'Invalid token'); + return await request.respondJson(403, message: 'Invalid token'); } // begin of actual file transfer @@ -452,7 +454,7 @@ class ReceiveController { name: finalName, saveToGallery: saveToGallery, isImage: fileType == FileType.image, - stream: request.read(), + stream: request, androidSdkInt: server.ref.read(deviceInfoProvider).androidSdkInt, lastModified: receivingFile.file.metadata?.lastModified, lastAccessed: receivingFile.file.metadata?.lastAccessed, @@ -467,7 +469,7 @@ class ReceiveController { }, ); if (server.getState().session == null || !allowedStates.contains(server.getState().session!.status)) { - return server.responseJson(500, message: 'Server is in invalid state'); + return await request.respondJson(500, message: 'Server is in invalid state'); } server.setState( (oldState) => oldState?.copyWith( @@ -539,48 +541,48 @@ class ReceiveController { } return server.getState().session?.files[fileId]?.status == FileStatus.finished - ? server.responseJson(200) - : server.responseJson(500, message: 'Could not save file. Check receiving device for more information.'); + ? await request.respondJson(200) + : await request.respondJson(500, message: 'Could not save file. Check receiving device for more information.'); } - Response _cancelHandler({ - required Request request, + Future _cancelHandler({ + required HttpRequest request, required bool v2, - }) { + }) async { final receiveSession = server.getState().session; if (receiveSession != null) { // We are currently receiving files. if (!v2 && receiveSession.sender.version != '1.0') { // disallow v1 cancel for active v2 sessions - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } if (receiveSession.sender.ip != request.ip) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } // require session id for v2 // don't require it when during waiting state if (v2 && receiveSession.status != SessionStatus.waiting) { - final sessionId = request.url.queryParameters['sessionId']; + final sessionId = request.uri.queryParameters['sessionId']; if (sessionId != receiveSession.sessionId) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } } // check if valid state final currentStatus = receiveSession.status; if (currentStatus != SessionStatus.waiting && currentStatus != SessionStatus.sending) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } _cancelBySender(server); - return server.responseJson(200); + return await request.respondJson(200); } else { // We are not receiving files so we may be sending files. - final sessionId = request.url.queryParameters['sessionId']; + final sessionId = request.uri.queryParameters['sessionId']; final sendSessions = server.ref.read(sendProvider); final SendSessionState sendState; if (v2) { @@ -588,7 +590,7 @@ class ReceiveController { final selectedSession = sendSessions.values.firstWhereOrNull((s) => s.remoteSessionId == sessionId); if (selectedSession == null) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } sendState = selectedSession; @@ -598,41 +600,41 @@ class ReceiveController { final onlySession = sendSessions.values.singleOrNull; if (onlySession == null) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } sendState = onlySession; } if (sendState.target.ip != request.ip) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } // check if valid state if (sendState.status != SessionStatus.sending) { - return server.responseJson(403, message: 'No permission'); + return await request.respondJson(403, message: 'No permission'); } server.ref.notifier(sendProvider).cancelSessionByReceiver( sendState.sessionId, ); - return server.responseJson(200); + return await request.respondJson(200); } } - Response _showHandler({ - required Request request, + Future _showHandler({ + required HttpRequest request, required String showToken, - }) { - final senderToken = request.url.queryParameters['token']; + }) async { + final senderToken = request.uri.queryParameters['token']; if (senderToken == showToken && checkPlatformIsDesktop()) { - // ignore: discarded_futures + // ignore: unawaited_futures showFromTray().catchError((e) { // don't wait for it _logger.severe('Failed to show from tray', e); }); - // ignore: discarded_futures + // ignore: unawaited_futures request.readAsString().then((body) async { if (body.isEmpty) { return; @@ -646,10 +648,10 @@ class ReceiveController { } }); - return server.responseJson(200); + return await request.respondJson(200); } - return server.responseJson(403, message: 'Invalid token'); + return await request.respondJson(403, message: 'Invalid token'); } void acceptFileRequest(Map fileNameMap) { diff --git a/app/lib/provider/network/server/controller/send_controller.dart b/app/lib/provider/network/server/controller/send_controller.dart index e39dc0cb..1cade3c9 100644 --- a/app/lib/provider/network/server/controller/send_controller.dart +++ b/app/lib/provider/network/server/controller/send_controller.dart @@ -18,8 +18,8 @@ import 'package:localsend_app/provider/device_info_provider.dart'; import 'package:localsend_app/provider/network/server/controller/common.dart'; import 'package:localsend_app/provider/network/server/server_utils.dart'; import 'package:localsend_app/provider/settings_provider.dart'; -import 'package:shelf/shelf.dart'; -import 'package:shelf_router/shelf_router.dart'; +import 'package:localsend_app/util/simple_server.dart'; +import 'package:localsend_app/util/stream.dart'; import 'package:uri_content/uri_content.dart'; import 'package:uuid/uuid.dart'; @@ -33,38 +33,38 @@ class SendController { /// Installs all routes for receiving files. void installRoutes({ - required Router router, + required SimpleServerRouteBuilder router, required String alias, required String fingerprint, }) { - router.get('/', (Request request) async { + router.get('/', (HttpRequest request) async { final state = server.getState(); if (state.webSendState == null) { // There is no web send state - return server.responseAsset(403, Assets.web.error403); + return await request.respondAsset(403, Assets.web.error403); } - return server.responseAsset(200, Assets.web.index); + return await request.respondAsset(200, Assets.web.index); }); - router.get('/main.js', (Request request) async { + router.get('/main.js', (HttpRequest request) async { final state = server.getState(); if (state.webSendState == null) { // There is no web send state - return server.responseAsset(403, Assets.web.error403); + return await request.respondAsset(403, Assets.web.error403); } - return server.responseAsset(200, Assets.web.main, 'text/javascript; charset=utf-8'); + return await request.respondAsset(200, Assets.web.main, 'text/javascript; charset=utf-8'); }); - router.get('/i18n.json', (Request request) async { + router.get('/i18n.json', (HttpRequest request) async { final state = server.getState(); if (state.webSendState == null) { // There is no web send state - return server.responseJson(403, message: 'Web send not initialized.'); + return await request.respondJson(403, message: 'Web send not initialized.'); } - return server.responseJson(200, body: { + return await request.respondJson(200, body: { 'waiting': t.web.waiting, 'enterPin': t.web.enterPin, 'invalidPin': t.web.invalidPin, @@ -76,20 +76,20 @@ class SendController { }); }); - router.post(ApiRoute.prepareDownload.v2, (Request request) async { + router.post(ApiRoute.prepareDownload.v2, (HttpRequest request) async { final state = server.getState(); if (state.webSendState == null) { // There is no web send state - return server.responseJson(403, message: 'Web send not initialized.'); + return request.respondJson(403, message: 'Web send not initialized.'); } - final requestSessionId = request.url.queryParameters['sessionId']; + final requestSessionId = request.uri.queryParameters['sessionId']; if (requestSessionId != null) { // Check if the user already has permission final session = server.getState().webSendState?.sessions[requestSessionId]; if (session != null && session.responseHandler == null && session.ip == request.ip) { final deviceInfo = server.ref.read(deviceInfoProvider); - return server.responseJson(200, + return await request.respondJson(200, body: ReceiveRequestResponseDto( info: InfoDto( alias: alias, @@ -107,14 +107,14 @@ class SendController { } } - final pinResponse = handlePin( + final pinCorrect = await checkPin( server: server, pin: state.webSendState!.pin, pinAttempts: state.webSendState!.pinAttempts, request: request, ); - if (pinResponse != null) { - return pinResponse; + if (!pinCorrect) { + return; } final streamController = StreamController(); @@ -148,7 +148,7 @@ class SendController { ), ), ); - return server.responseJson(403, message: 'File transfer rejected.'); + return await request.respondJson(403, message: 'File transfer rejected.'); } server.setState( @@ -164,7 +164,7 @@ class SendController { ), ); final deviceInfo = server.ref.read(deviceInfoProvider); - return server.responseJson(200, + return await request.respondJson(200, body: ReceiveRequestResponseDto( info: InfoDto( alias: alias, @@ -181,55 +181,47 @@ class SendController { ).toJson()); }); - router.get(ApiRoute.download.v2, (Request request) async { - final sessionId = request.url.queryParameters['sessionId']; + router.get(ApiRoute.download.v2, (HttpRequest request) async { + final sessionId = request.uri.queryParameters['sessionId']; if (sessionId == null) { - return server.responseJson(400, message: 'Missing sessionId.'); + return await request.respondJson(400, message: 'Missing sessionId.'); } final session = server.getState().webSendState?.sessions[sessionId]; if (session == null || session.responseHandler != null || session.ip != request.ip) { - return server.responseJson(403, message: 'Invalid sessionId.'); + return await request.respondJson(403, message: 'Invalid sessionId.'); } - final fileId = request.url.queryParameters['fileId']; + final fileId = request.uri.queryParameters['fileId']; if (fileId == null) { - return server.responseJson(400, message: 'Missing fileId.'); + return await request.respondJson(400, message: 'Missing fileId.'); } final file = server.getState().webSendState?.files[fileId]; if (file == null) { - return server.responseJson(403, message: 'Invalid fileId.'); + return await request.respondJson(403, message: 'Invalid fileId.'); } final fileName = file.file.fileName.replaceAll('/', '-'); // File name may be inside directories - final headers = { - 'content-type': 'application/octet-stream', - 'content-disposition': 'attachment; filename="${Uri.encodeComponent(fileName)}"', - 'content-length': '${file.file.size}', - }; + + request.response + ..statusCode = 200 + ..headers.set('content-type', 'application/octet-stream') + ..headers.set('content-disposition', 'attachment; filename="${Uri.encodeComponent(fileName)}"') + ..headers.set('content-length', '${file.file.size}'); if (file.bytes != null) { - return Response( - 200, - body: file.bytes!, - headers: headers, - ); + request.response.write(file.bytes!); + await request.response.close(); } else { final path = file.path!; - if (path.startsWith('content://')) { - return Response( - 200, - body: UriContent().getContentStream(Uri.parse(file.path!)), - headers: headers, - ); - } else { - return Response( - 200, - body: File(file.path!).openRead().asBroadcastStream(), - headers: headers, - ); - } + final fileStream = path.startsWith('content://') ? UriContent().getContentStream(Uri.parse(file.path!)) : File(file.path!).openRead(); + final (streamController, subscription) = fileStream.digested(); + + await request.response.addStream(streamController.stream).then((_) { + request.response.close(); + subscription.cancel(); + }); } }); } diff --git a/app/lib/provider/network/server/server_provider.dart b/app/lib/provider/network/server/server_provider.dart index 52e177e8..bf55c11f 100644 --- a/app/lib/provider/network/server/server_provider.dart +++ b/app/lib/provider/network/server/server_provider.dart @@ -12,10 +12,9 @@ import 'package:localsend_app/provider/network/server/server_utils.dart'; import 'package:localsend_app/provider/security_provider.dart'; import 'package:localsend_app/provider/settings_provider.dart'; import 'package:localsend_app/util/alias_generator.dart'; +import 'package:localsend_app/util/simple_server.dart'; import 'package:logging/logging.dart'; import 'package:refena_flutter/refena_flutter.dart'; -import 'package:shelf/shelf_io.dart'; -import 'package:shelf_router/shelf_router.dart'; final _logger = Logger('Server'); @@ -98,7 +97,7 @@ class ServerService extends Notifier { port = defaultPort; } - final router = Router(); + final router = SimpleServerRouteBuilder(); final fingerprint = ref.read(securityProvider).certificateHash; _receiveController.installRoutes( router: router, @@ -115,50 +114,45 @@ class ServerService extends Notifier { ); _logger.info('Starting server...'); - ServerState? newServerState; + final HttpServer httpServer; if (https) { final securityContext = ref.read(securityProvider); - newServerState = ServerState( - httpServer: await _startServer( - router: router, - port: port, - securityContext: SecurityContext() - ..usePrivateKeyBytes(securityContext.privateKey.codeUnits) - ..useCertificateChainBytes(securityContext.certificate.codeUnits), - ), - alias: alias, - port: port, - https: true, - session: null, - webSendState: null, - pinAttempts: {}, + httpServer = await HttpServer.bindSecure( + '0.0.0.0', + port, + SecurityContext() + ..usePrivateKeyBytes(securityContext.privateKey.codeUnits) + ..useCertificateChainBytes(securityContext.certificate.codeUnits), ); - _logger.info('Server started. (Port: ${newServerState.port}, HTTPS only)'); + _logger.info('Server started. (Port: $port, HTTPS only)'); } else { - newServerState = ServerState( - httpServer: await _startServer( - router: router, - port: port, - securityContext: null, - ), - alias: alias, - port: port, - https: false, - session: null, - webSendState: null, - pinAttempts: {}, + httpServer = await HttpServer.bind( + '0.0.0.0', + port, ); - _logger.info('Server started. (Port: ${newServerState.port}, HTTP only)'); + _logger.info('Server started. (Port: $port, HTTP only)'); } + final server = SimpleServer.start(server: httpServer, routes: router); + + final newServerState = ServerState( + httpServer: server, + alias: alias, + port: port, + https: https, + session: null, + webSendState: null, + pinAttempts: {}, + ); + state = newServerState; return newServerState; } Future stopServer() async { _logger.info('Stopping server...'); - await state?.httpServer.close(force: true); + await state?.httpServer.close(); state = null; _logger.info('Server stopped.'); } @@ -235,21 +229,6 @@ class ServerService extends Notifier { } } -/// Starts the (actual) server. -/// This binds the server to the given [port] and returns the [HttpServer] instance. -Future _startServer({ - required Router router, - required int port, - required SecurityContext? securityContext, -}) async { - return serve( - router.call, - '0.0.0.0', - port, - securityContext: securityContext, - ); -} - // Below is a first prototype of mTLS (mutual TLS). // Problem: // - we cannot request client certificates while ignoring errors diff --git a/app/lib/provider/network/server/server_utils.dart b/app/lib/provider/network/server/server_utils.dart index 131ed940..c64205b6 100644 --- a/app/lib/provider/network/server/server_utils.dart +++ b/app/lib/provider/network/server/server_utils.dart @@ -1,11 +1,5 @@ -import 'dart:convert'; -import 'dart:io'; - -import 'package:flutter/services.dart' show rootBundle; import 'package:localsend_app/model/state/server/server_state.dart'; -import 'package:localsend_app/util/user_agent_analyzer.dart'; import 'package:refena_flutter/refena_flutter.dart'; -import 'package:shelf/shelf.dart'; /// Having this class allows us to have one parameter to access all relevant server methods. class ServerUtils { @@ -24,30 +18,6 @@ class ServerUtils { /// Updates the server state. void Function(ServerState? Function(ServerState? oldState) builder) setState; - /// Syntax sugar for creating an API response. - /// - /// Usage: - /// responseApi(200, message: 'Hello World') - Response responseJson(int code, {String? message, Map? body}) { - return Response( - code, - body: jsonEncode(message != null ? {'message': message} : (body ?? {})), - headers: {'content-type': 'application/json'}, - ); - } - - /// Syntax sugar for creating a HTML response. - /// - /// Usage: - /// responseHtml(200, 'assets/web/index.html') - Future responseAsset(int code, String asset, [String type = 'text/html; charset=utf-8']) async { - return Response( - code, - body: await rootBundle.loadString(asset), - headers: {'content-type': type}, - ); - } - ServerUtils({ required this.refFunc, required this.getState, @@ -55,31 +25,3 @@ class ServerUtils { required this.setState, }); } - -extension RequestExt on Request { - /// The IP address of the client. - String get ip { - return (context['shelf.io.connection_info'] as HttpConnectionInfo).remoteAddress.address; - } - - /// Client's device info parsed from the user agent. - String get deviceInfo { - final userAgent = headers['user-agent']; - if (userAgent == null) { - return 'Unknown'; - } - - final userAgentAnalyzer = UserAgentAnalyzer(); - final browser = userAgentAnalyzer.getBrowser(userAgent); - final os = userAgentAnalyzer.getOS(userAgent); - if (browser != null && os != null) { - return '$browser ($os)'; - } else if (browser != null) { - return browser; - } else if (os != null) { - return os; - } else { - return 'Unknown'; - } - } -} diff --git a/app/lib/util/simple_server.dart b/app/lib/util/simple_server.dart new file mode 100644 index 00000000..01af6548 --- /dev/null +++ b/app/lib/util/simple_server.dart @@ -0,0 +1,141 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter/services.dart'; +import 'package:localsend_app/util/user_agent_analyzer.dart'; + +/// A very light layer on top of the HttpServer class. +class SimpleServer { + final HttpServer _server; + + SimpleServer.start({ + required HttpServer server, + required SimpleServerRouteBuilder routes, + }) : _server = server { + _server.listen((request) async { + final handler = routes._routes[Route( + HttpMethod.values.firstWhere((e) => e.methodName == request.method), + request.uri.path, + )]; + + if (handler != null) { + handler.call(request); + } else { + request.response.statusCode = HttpStatus.notFound; + request.response.write('Not found'); + await request.response.flush(); + await request.response.close(); + } + }); + } + + Future close() async { + await _server.close(force: true); + } +} + +typedef HttpRequestHandler = void Function(HttpRequest request); + +enum HttpMethod { + get('GET'), + post('POST'), + ; + + const HttpMethod(this.methodName); + + final String methodName; +} + +class Route { + final HttpMethod method; + final String path; + + Route(this.method, this.path); + + @override + bool operator ==(Object other) { + if (identical(this, other)) return true; + + return other is Route && other.method == method && other.path == path; + } + + @override + int get hashCode => method.hashCode ^ path.hashCode; +} + +class SimpleServerRouteBuilder { + final Map _routes = {}; + + void addRoute(HttpMethod method, String path, HttpRequestHandler handler) { + _routes[Route( + method, + path, + )] = handler; + } + + void get(String path, HttpRequestHandler handler) { + addRoute(HttpMethod.get, path, handler); + } + + void post(String path, HttpRequestHandler handler) { + addRoute(HttpMethod.post, path, handler); + } +} + +extension RequestExt on HttpRequest { + Future readAsString() async { + return utf8.decodeStream(this); + } + + /// Syntax sugar for creating an API response. + /// + /// Usage: + /// respondJson(200, message: 'Hello World') + Future respondJson(int code, {String? message, Map? body}) async { + response + ..statusCode = code + ..headers.contentType = ContentType.json + ..write(jsonEncode(message != null ? {'message': message} : (body ?? {}))); + + await response.close(); + } + + /// Syntax sugar for creating an HTML response. + /// + /// Usage: + /// respondAsset(200, 'assets/web/index.html') + Future respondAsset(int code, String asset, [String type = 'text/html; charset=utf-8']) async { + response + ..statusCode = code + ..headers.contentType = ContentType.parse(type) + ..write(await rootBundle.loadString(asset)); + + await response.close(); + } + + /// The IP address of the client. + String get ip { + return connectionInfo!.remoteAddress.address; + } + + /// Client's device info parsed from the user agent. + String get deviceInfo { + final userAgent = headers['user-agent']?.first; + if (userAgent == null) { + return 'Unknown'; + } + + final userAgentAnalyzer = UserAgentAnalyzer(); + final browser = userAgentAnalyzer.getBrowser(userAgent); + final os = userAgentAnalyzer.getOS(userAgent); + if (browser != null && os != null) { + return '$browser ($os)'; + } else if (browser != null) { + return browser; + } else if (os != null) { + return os; + } else { + return 'Unknown'; + } + } +} diff --git a/app/lib/util/stream.dart b/app/lib/util/stream.dart new file mode 100644 index 00000000..59537623 --- /dev/null +++ b/app/lib/util/stream.dart @@ -0,0 +1,21 @@ +import 'dart:async'; + +extension StreamExt on Stream { + (StreamController, StreamSubscription) digested() { + late StreamSubscription subscription; + final streamController = StreamController( + onListen: () => subscription.resume(), + onPause: () => subscription.pause(), + onResume: () => subscription.resume(), + onCancel: () async => await subscription.cancel(), + ); + + subscription = listen( + (data) => streamController.add(data), + onError: (e, st) => streamController.addError(e, st), + onDone: () async => await streamController.close(), + ); + + return (streamController, subscription); + } +} diff --git a/app/pubspec.lock b/app/pubspec.lock index 43f0ba4d..3616cf1b 100644 --- a/app/pubspec.lock +++ b/app/pubspec.lock @@ -596,14 +596,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.0" - http_methods: - dependency: transitive - description: - name: http_methods - sha256: "6bccce8f1ec7b5d701e7921dca35e202d425b57e317ba1a37f2638590e29e566" - url: "https://pub.dev" - source: hosted - version: "1.1.1" http_multi_server: dependency: transitive description: @@ -1278,7 +1270,7 @@ packages: source: hosted version: "0.8.1" shelf: - dependency: "direct main" + dependency: transitive description: name: shelf sha256: ad29c505aee705f41a4d8963641f91ac4cee3c8fad5947e033390a7bd8180fa4 @@ -1293,14 +1285,6 @@ packages: url: "https://pub.dev" source: hosted version: "3.0.2" - shelf_router: - dependency: "direct main" - description: - name: shelf_router - sha256: f5e5d492440a7fb165fe1e2e1a623f31f734d3370900070b2b1e0d0428d59864 - url: "https://pub.dev" - source: hosted - version: "1.1.4" shelf_static: dependency: transitive description: diff --git a/app/pubspec.yaml b/app/pubspec.yaml index 9d1fdc7d..ecd75c89 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -58,8 +58,6 @@ dependencies: shared_preferences: 2.2.3 shared_preferences_platform_interface: any shared_storage: 0.8.1 - shelf: 1.4.1 - shelf_router: 1.1.4 slang: 3.31.1 slang_flutter: 3.31.0 system_settings: 2.1.0