Files
localsend/app/lib/provider/network/server/controller/send_controller.dart
T
Tien Do Nam a6772e7c16
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled
feat: PIN on web receive
2026-08-04 03:42:16 +02:00

229 lines
7.9 KiB
Dart

import 'dart:convert';
import 'dart:io';
import 'package:localsend_app/model/cross_file.dart';
import 'package:localsend_app/model/state/send/web/web_send_file.dart';
import 'package:localsend_app/model/state/send/web/web_send_session.dart';
import 'package:localsend_app/model/state/send/web/web_send_state.dart';
import 'package:localsend_app/provider/network/server/server_utils.dart';
import 'package:localsend_app/provider/settings_provider.dart';
import 'package:localsend_app/util/native/directories.dart';
import 'package:localsend_app/util/user_agent_analyzer.dart';
import 'package:localsend_isolates/isolate.dart';
import 'package:localsend_isolates/model/dto/file_dto.dart';
import 'package:localsend_isolates/model/file_type.dart';
import 'package:localsend_isolates/util/android_channel.dart' as isolate_android_channel;
import 'package:logging/logging.dart';
import 'package:path/path.dart' as p;
import 'package:uuid/uuid.dart';
const _uuid = Uuid();
final _logger = Logger('WebSendController');
/// Handles all server events for web send (sending files to web browsers).
/// The web page and the downloads themselves are served by the Rust server
/// which emits the events handled here.
class SendController {
final ServerUtils server;
SendController(this.server);
/// Builds the [WebSendState] for the given [files].
/// Files that only exist in memory (e.g. text messages) are materialized
/// to the cache directory so the Rust server can stream them.
Future<WebSendState> buildWebSendState({required List<CrossFile> files}) async {
final currentWebSendState = server.getStateOrNull()?.webSendState;
return WebSendState(
sessions: {},
files: Map.fromEntries(
await Future.wait(
files.map((file) async {
final id = _uuid.v4();
String? path = file.path;
if (path == null && file.bytes != null) {
// The Rust server streams file content from disk, so in-memory
// bytes (text messages, clipboard content) are written to a temp file.
final tempPath = p.join(await getCacheDirectory(), 'web-send-$id');
await File(tempPath).writeAsBytes(file.bytes!);
path = tempPath;
}
return MapEntry(
id,
WebSendFile(
file: FileDto(
id: id,
fileName: file.name,
size: file.size,
fileType: file.fileType,
hash: null,
preview: files.first.fileType == FileType.text && files.first.bytes != null
? utf8.decode(files.first.bytes!) // send simple message by embedding it into the preview
: null,
metadata: file.lastModified != null || file.lastAccessed != null
? FileMetadata(
lastModified: file.lastModified,
lastAccessed: file.lastAccessed,
)
: null,
),
asset: file.asset,
path: path,
bytes: file.bytes,
),
);
}),
),
),
autoAccept: currentWebSendState?.autoAccept ?? server.ref.read(settingsProvider).shareViaLinkAutoAccept,
);
}
/// A web client requests to download the shared files.
/// The Rust server already checked the PIN and handles repeated visits of
/// accepted sessions itself.
void onPrepareDownload(HttpServerWebPrepareDownloadEvent event) {
final webSendState = server.getStateOrNull()?.webSendState;
if (webSendState == null) {
// should not happen: web send events are only emitted when web send was configured
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareDownloadDecisionAction(sessionId: event.sessionId, accept: false));
return;
}
server.setState(
(oldState) => oldState!.copyWith(
webSendState: oldState.webSendState!.copyWith(
sessions: {
...oldState.webSendState!.sessions,
event.sessionId: WebSendSession(
sessionId: event.sessionId,
pending: true,
ip: event.ip,
deviceInfo: parseDeviceInfoFromUserAgent(event.userAgent),
),
},
),
),
);
if (webSendState.autoAccept) {
acceptRequest(event.sessionId);
}
}
/// A web client downloads an offered file.
/// The Rust server already validated the session; it streams the content
/// from the source resolved here.
Future<void> onFileDownload(HttpServerWebFileDownloadEvent event) async {
final String? filePath;
final int? fileDescriptor;
try {
final path = server.getStateOrNull()?.webSendState?.files[event.fileId]?.path;
if (path == null) {
// should not happen: the Rust server only emits events for offered files
throw StateError('No path for web send file ${event.fileId}');
}
if (path.startsWith('content://')) {
filePath = null;
fileDescriptor = await isolate_android_channel.getFileDescriptorAndroid(uri: path);
} else {
filePath = path;
fileDescriptor = null;
}
} catch (e, st) {
_logger.severe('Failed to resolve source for web send file ${event.fileId}', e, st);
// Unblock the web client's request waiting for the content source.
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerFailFileDownloadAction(sessionId: event.sessionId, fileId: event.fileId));
return;
}
server.ref
.redux(parentIsolateProvider)
.dispatch(
IsolateHttpServerFileDownloadTargetAction(
sessionId: event.sessionId,
fileId: event.fileId,
path: filePath,
fileDescriptor: fileDescriptor,
),
);
}
void acceptRequest(String sessionId) {
final session = server.getStateOrNull()?.webSendState?.sessions[sessionId];
if (session == null || !session.pending) {
return;
}
server.setState(
(oldState) => oldState!.copyWith(
webSendState: oldState.webSendState!.updateSession(
sessionId: sessionId,
update: (oldSession) => oldSession.copyWith(pending: false),
),
),
);
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareDownloadDecisionAction(sessionId: sessionId, accept: true));
}
void declineRequest(String sessionId) {
final session = server.getStateOrNull()?.webSendState?.sessions[sessionId];
if (session == null || !session.pending) {
return;
}
server.setState(
(oldState) => oldState!.copyWith(
webSendState: oldState.webSendState!.copyWith(
sessions: {
for (final entry in oldState.webSendState!.sessions.entries)
if (entry.key != sessionId) entry.key: entry.value, // remove session
},
),
),
);
server.ref.redux(parentIsolateProvider).dispatch(IsolateHttpServerPrepareDownloadDecisionAction(sessionId: sessionId, accept: false));
}
}
/// Parses a human-readable device description from a user agent.
String parseDeviceInfoFromUserAgent(String? userAgent) {
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';
}
}
extension on WebSendState {
WebSendState updateSession({
required String sessionId,
required WebSendSession Function(WebSendSession oldSession) update,
}) {
return copyWith(
sessions: {...sessions}
..update(
sessionId,
(session) => update(session),
),
);
}
}