mirror of
https://github.com/localsend/localsend.git
synced 2026-08-07 07:14:52 +00:00
feat: use HttpServer instead of shelf
This commit is contained in:
@@ -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,
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user