feat: use HttpServer instead of shelf

This commit is contained in:
Tien Do Nam
2024-08-20 02:16:15 +02:00
parent 79791e1b2d
commit 52edff436c
12 changed files with 349 additions and 303 deletions
+2 -3
View File
@@ -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;
@@ -22,8 +22,8 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
@override
final String id = 'ServerState';
static HttpServer _$httpServer(ServerState v) => v.httpServer;
static const Field<ServerState, HttpServer> _f$httpServer = Field('httpServer', _$httpServer);
static SimpleServer _$httpServer(ServerState v) => v.httpServer;
static const Field<ServerState, SimpleServer> _f$httpServer = Field('httpServer', _$httpServer);
static String _$alias(ServerState v) => v.alias;
static const Field<ServerState, String> _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,
+2 -19
View File
@@ -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<Map<String, SendSessionState>> {
: File(file.path!).openRead()
: null;
final StreamController<List<int>>? streamController;
StreamSubscription<List<int>>? subscription;
if (fileStream != null) {
streamController = StreamController<List<int>>(
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 {
@@ -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<bool> checkPin({
required ServerUtils server,
required String? pin,
required Map<String, int> 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;
}
@@ -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<void> _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<Response> _registerHandler({
required Request request,
Future<void> _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<Response> _prepareUploadHandler({
required Request request,
Future<void> _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<Response> _uploadHandler({
required Request request,
Future<void> _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<void> _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<void> _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<String, String> fileNameMap) {
@@ -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<bool>();
@@ -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();
});
}
});
}
@@ -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<ServerState?> {
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<ServerState?> {
);
_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<void> 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<ServerState?> {
}
}
/// Starts the (actual) server.
/// This binds the server to the given [port] and returns the [HttpServer] instance.
Future<HttpServer> _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
@@ -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<String, dynamic>? 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<Response> 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';
}
}
}
+141
View File
@@ -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<void> 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<Route, HttpRequestHandler> _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<String> readAsString() async {
return utf8.decodeStream(this);
}
/// Syntax sugar for creating an API response.
///
/// Usage:
/// respondJson(200, message: 'Hello World')
Future<void> respondJson(int code, {String? message, Map<String, dynamic>? 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<void> 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';
}
}
}
+21
View File
@@ -0,0 +1,21 @@
import 'dart:async';
extension StreamExt<T> on Stream<T> {
(StreamController<T>, StreamSubscription<T>) digested() {
late StreamSubscription<T> subscription;
final streamController = StreamController<T>(
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);
}
}
+1 -17
View File
@@ -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:
-2
View File
@@ -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