diff --git a/app/lib/model/state/send/web/web_send_state.dart b/app/lib/model/state/send/web/web_send_state.dart index 5988679d..37fad3cb 100644 --- a/app/lib/model/state/send/web/web_send_state.dart +++ b/app/lib/model/state/send/web/web_send_state.dart @@ -9,17 +9,15 @@ class WebSendState with WebSendStateMappable { final Map sessions; // session id -> session data, also includes incoming requests final Map files; // file id as key final bool autoAccept; // automatically accept incoming requests - final String? pin; // the PIN is enforced by the Rust server; changing it requires a server restart const WebSendState({ required this.sessions, required this.files, required this.autoAccept, - required this.pin, }); @override String toString() { - return 'WebSendState(sessions: $sessions, files: <${files.keys}>, autoAccept: $autoAccept, pin: $pin)'; + return 'WebSendState(sessions: $sessions, files: <${files.keys}>, autoAccept: $autoAccept)'; } } diff --git a/app/lib/model/state/send/web/web_send_state.mapper.dart b/app/lib/model/state/send/web/web_send_state.mapper.dart index 372f5ea2..c2eb3e7d 100644 --- a/app/lib/model/state/send/web/web_send_state.mapper.dart +++ b/app/lib/model/state/send/web/web_send_state.mapper.dart @@ -37,15 +37,12 @@ class WebSendStateMapper extends ClassMapperBase { 'autoAccept', _$autoAccept, ); - static String? _$pin(WebSendState v) => v.pin; - static const Field _f$pin = Field('pin', _$pin); @override final MappableFields fields = const { #sessions: _f$sessions, #files: _f$files, #autoAccept: _f$autoAccept, - #pin: _f$pin, }; static WebSendState _instantiate(DecodingData data) { @@ -53,7 +50,6 @@ class WebSendStateMapper extends ClassMapperBase { sessions: data.dec(_f$sessions), files: data.dec(_f$files), autoAccept: data.dec(_f$autoAccept), - pin: data.dec(_f$pin), ); } @@ -137,7 +133,6 @@ abstract class WebSendStateCopyWith<$R, $In extends WebSendState, $Out> Map? sessions, Map? files, bool? autoAccept, - String? pin, }); WebSendStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -179,13 +174,11 @@ class _WebSendStateCopyWithImpl<$R, $Out> Map? sessions, Map? files, bool? autoAccept, - Object? pin = $none, }) => $apply( FieldCopyWithData({ if (sessions != null) #sessions: sessions, if (files != null) #files: files, if (autoAccept != null) #autoAccept: autoAccept, - if (pin != $none) #pin: pin, }), ); @override @@ -193,7 +186,6 @@ class _WebSendStateCopyWithImpl<$R, $Out> sessions: data.get(#sessions, or: $value.sessions), files: data.get(#files, or: $value.files), autoAccept: data.get(#autoAccept, or: $value.autoAccept), - pin: data.get(#pin, or: $value.pin), ); @override diff --git a/app/lib/model/state/server/server_state.dart b/app/lib/model/state/server/server_state.dart index 62ede98a..567ee578 100644 --- a/app/lib/model/state/server/server_state.dart +++ b/app/lib/model/state/server/server_state.dart @@ -15,6 +15,10 @@ class ServerState with ServerStateMappable { /// Whether the upload page is served so web browsers can upload files. final bool webUpload; + /// Optional session-scoped PIN of the active web share mode (download or upload page). + /// The PIN is enforced by the Rust server; changing it requires a server restart. + final String? webPin; + const ServerState({ required this.alias, required this.port, @@ -22,10 +26,11 @@ class ServerState with ServerStateMappable { required this.session, required this.webSendState, required this.webUpload, + required this.webPin, }); @override String toString() { - return 'ServerState(alias: $alias, port: $port, https: $https, session: $session, webSendState: $webSendState, webUpload: $webUpload)'; + return 'ServerState(alias: $alias, port: $port, https: $https, session: $session, webSendState: $webSendState, webUpload: $webUpload, webPin: $webPin)'; } } diff --git a/app/lib/model/state/server/server_state.mapper.dart b/app/lib/model/state/server/server_state.mapper.dart index 585c35d2..216eb7d4 100644 --- a/app/lib/model/state/server/server_state.mapper.dart +++ b/app/lib/model/state/server/server_state.mapper.dart @@ -45,6 +45,8 @@ class ServerStateMapper extends ClassMapperBase { 'webUpload', _$webUpload, ); + static String? _$webPin(ServerState v) => v.webPin; + static const Field _f$webPin = Field('webPin', _$webPin); @override final MappableFields fields = const { @@ -54,6 +56,7 @@ class ServerStateMapper extends ClassMapperBase { #session: _f$session, #webSendState: _f$webSendState, #webUpload: _f$webUpload, + #webPin: _f$webPin, }; static ServerState _instantiate(DecodingData data) { @@ -64,6 +67,7 @@ class ServerStateMapper extends ClassMapperBase { session: data.dec(_f$session), webSendState: data.dec(_f$webSendState), webUpload: data.dec(_f$webUpload), + webPin: data.dec(_f$webPin), ); } @@ -137,6 +141,7 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out> ReceiveSessionState? session, WebSendState? webSendState, bool? webUpload, + String? webPin, }); ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); } @@ -163,6 +168,7 @@ class _ServerStateCopyWithImpl<$R, $Out> Object? session = $none, Object? webSendState = $none, bool? webUpload, + Object? webPin = $none, }) => $apply( FieldCopyWithData({ if (alias != null) #alias: alias, @@ -171,6 +177,7 @@ class _ServerStateCopyWithImpl<$R, $Out> if (session != $none) #session: session, if (webSendState != $none) #webSendState: webSendState, if (webUpload != null) #webUpload: webUpload, + if (webPin != $none) #webPin: webPin, }), ); @override @@ -181,6 +188,7 @@ class _ServerStateCopyWithImpl<$R, $Out> session: data.get(#session, or: $value.session), webSendState: data.get(#webSendState, or: $value.webSendState), webUpload: data.get(#webUpload, or: $value.webUpload), + webPin: data.get(#webPin, or: $value.webPin), ); @override diff --git a/app/lib/pages/web_share_page.dart b/app/lib/pages/web_share_page.dart index b9228953..2c337d6c 100644 --- a/app/lib/pages/web_share_page.dart +++ b/app/lib/pages/web_share_page.dart @@ -62,8 +62,15 @@ class _WebSharePageState extends State with Refena { await sleepAsync(500); try { final files = widget.files; + + // The pin of a previous web share session is kept; + // receive mode initially uses the receive pin from settings. + final previousState = ref.read(serverProvider); + final wasWebActive = previousState?.webSendState != null || previousState?.webUpload == true; + final webPin = wasWebActive ? previousState?.webPin : (files == null ? settings.receivePin : null); + if (files != null) { - // The auto accept setting and the pin of a previous web send state are kept. + // The auto accept setting of a previous web send state is kept. await ref .notifier(serverProvider) .restartServerWithWebSend( @@ -71,6 +78,7 @@ class _WebSharePageState extends State with Refena { port: settings.port, https: _encrypted, files: files, + webPin: webPin, ); } else { await ref @@ -80,6 +88,7 @@ class _WebSharePageState extends State with Refena { port: settings.port, https: _encrypted, webUpload: true, + webPin: webPin, ); } setState(() { @@ -169,6 +178,7 @@ class _WebSharePageState extends State with Refena { } final networkState = context.watch(localIpProvider); final settings = context.watch(settingsProvider); + final pin = serverState.webPin; return ResponsiveListView( padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20), @@ -184,8 +194,8 @@ class _WebSharePageState extends State with Refena { children: [ ...networkState.localIps.map((ip) { final url = '${_encrypted ? 'https' : 'http'}://$ip:${serverState.port}'; - final urlWithPin = switch (webSendState?.pin) { - String() => '$url/?pin=${Uri.encodeQueryComponent(webSendState!.pin!)}', + final urlWithPin = switch (pin) { + String() => '$url/?pin=${Uri.encodeQueryComponent(pin)}', null => url, }; return Padding( @@ -217,7 +227,7 @@ class _WebSharePageState extends State with Refena { data: urlWithPin, label: url, listenIncomingWebSendRequests: _sendMode, - pin: webSendState?.pin, + pin: pin, ), ); }, @@ -233,7 +243,7 @@ class _WebSharePageState extends State with Refena { builder: (_) => ZoomDialog( label: url, listenIncomingWebSendRequests: _sendMode, - pin: webSendState?.pin, + pin: pin, ), ); }, @@ -354,41 +364,38 @@ class _WebSharePageState extends State with Refena { ), ], ), - if (webSendState != null) ...[ - Row( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text(t.webSharePage.requirePin, style: Theme.of(context).textTheme.titleMedium), - const SizedBox(width: 10), - Checkbox( - value: webSendState.pin != null, - onChanged: (value) async { - final currentPIN = webSendState.pin; - if (currentPIN != null) { - await ref.notifier(serverProvider).setWebSendPin(null); - } else { - final String? newPin = await showDialog( - context: context, - builder: (_) => const PinDialog( - obscureText: false, - generateRandom: true, - ), - ); + Row( + crossAxisAlignment: CrossAxisAlignment.center, + children: [ + Text(t.webSharePage.requirePin, style: Theme.of(context).textTheme.titleMedium), + const SizedBox(width: 10), + Checkbox( + value: pin != null, + onChanged: (value) async { + if (pin != null) { + await ref.notifier(serverProvider).setWebPin(null); + } else { + final String? newPin = await showDialog( + context: context, + builder: (_) => const PinDialog( + obscureText: false, + generateRandom: true, + ), + ); - if (newPin != null && newPin.isNotEmpty) { - await ref.notifier(serverProvider).setWebSendPin(newPin); - } + if (newPin != null && newPin.isNotEmpty) { + await ref.notifier(serverProvider).setWebPin(newPin); } - }, - ), - ], - ), - if (webSendState.pin != null) - Text( - t.webSharePage.pinHint(pin: webSendState.pin!), - style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning), + } + }, ), - ], + ], + ), + if (pin != null) + Text( + t.webSharePage.pinHint(pin: pin), + style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning), + ), ], ); }, diff --git a/app/lib/provider/network/server/controller/send_controller.dart b/app/lib/provider/network/server/controller/send_controller.dart index d3e2645e..6f462c11 100644 --- a/app/lib/provider/network/server/controller/send_controller.dart +++ b/app/lib/provider/network/server/controller/send_controller.dart @@ -79,7 +79,6 @@ class SendController { ), ), autoAccept: currentWebSendState?.autoAccept ?? server.ref.read(settingsProvider).shareViaLinkAutoAccept, - pin: currentWebSendState?.pin, ); } diff --git a/app/lib/provider/network/server/server_provider.dart b/app/lib/provider/network/server/server_provider.dart index a37a23a8..80b6fb78 100644 --- a/app/lib/provider/network/server/server_provider.dart +++ b/app/lib/provider/network/server/server_provider.dart @@ -109,12 +109,15 @@ class ServerService extends Notifier { /// Starts the server. /// Passing a [webSendState] additionally serves the web send (download) API. /// Passing [webUpload] serves the upload page so web browsers can upload files. + /// [webPin] protects the active web share mode: the download page in send mode; + /// in upload mode, it replaces the receive pin from settings. Future startServer({ required String alias, required int port, required bool https, WebSendState? webSendState, bool webUpload = false, + String? webPin, }) async { if (state != null) { _logger.info('Server already running.'); @@ -141,7 +144,7 @@ class ServerService extends Notifier { .redux(parentIsolateProvider) .dispatchTakeResult( IsolateHttpServerStartAction( - pin: settings.receivePin, + pin: webUpload ? webPin : settings.receivePin, verifyChecksums: settings.verifyChecksums, web: webSendState != null || webUpload ? WebParams( @@ -150,7 +153,7 @@ class ServerService extends Notifier { files: { for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(), }, - pin: webSendState.pin, + pin: webPin, ) : null, upload: webUpload, @@ -210,6 +213,7 @@ class ServerService extends Notifier { session: null, webSendState: webSendState, webUpload: webUpload, + webPin: webSendState != null || webUpload ? webPin : null, ); state = newServerState; @@ -237,9 +241,10 @@ class ServerService extends Notifier { required bool https, WebSendState? webSendState, bool webUpload = false, + String? webPin, }) async { await stopServer(); - return await startServer(alias: alias, port: port, https: https, webSendState: webSendState, webUpload: webUpload); + return await startServer(alias: alias, port: port, https: https, webSendState: webSendState, webUpload: webUpload, webPin: webPin); } Future acceptFileRequest(Map fileNameMap) async { @@ -271,23 +276,23 @@ class ServerService extends Notifier { } /// Restarts the server with web send (the download API) enabled for [files]. - /// The auto accept setting and the pin of a previous web send state are kept. + /// The auto accept setting of a previous web send state is kept. Future restartServerWithWebSend({ required String alias, required int port, required bool https, required List files, + String? webPin, }) async { final webSendState = await _sendController.buildWebSendState(files: files); - await restartServer(alias: alias, port: port, https: https, webSendState: webSendState); + await restartServer(alias: alias, port: port, https: https, webSendState: webSendState, webPin: webPin); } - /// Updates the web send pin. + /// Updates the pin of the active web share mode (download or upload page). /// The pin is enforced by the Rust server, so the server is restarted. - Future setWebSendPin(String? pin) async { + Future setWebPin(String? pin) async { final current = state; - final webSendState = current?.webSendState; - if (current == null || webSendState == null || webSendState.pin == pin) { + if (current == null || (current.webSendState == null && !current.webUpload) || current.webPin == pin) { return; } @@ -295,7 +300,9 @@ class ServerService extends Notifier { alias: current.alias, port: current.port, https: current.https, - webSendState: webSendState.copyWith(sessions: {}, pin: pin), + webSendState: current.webSendState?.copyWith(sessions: {}), + webUpload: current.webUpload, + webPin: pin, ); } diff --git a/packages/core/assets/web/upload.html b/packages/core/assets/web/upload.html index 95c242ce..c1edfed1 100644 --- a/packages/core/assets/web/upload.html +++ b/packages/core/assets/web/upload.html @@ -68,6 +68,17 @@ var BASE_URL = '/api/localsend/v2'; var i18n = {}; + var queryParams = location.search.slice(1).split('&'); + var pin = null; + + // Parse query parameters manually for IE + for (var i = 0; i < queryParams.length; i++) { + var pair = queryParams[i].split('='); + if (pair[0] === 'pin') { + pin = decodeURIComponent(pair[1]); + break; + } + } function makeRequest(url, method, body, callback) { var xhr = new XMLHttpRequest(); @@ -154,7 +165,32 @@ files: filesDto }); - makeRequest(BASE_URL + '/prepare-upload', 'POST', body, function (response) { + prepareUpload(body, selectedFiles, true); + } + + function prepareUpload(body, selectedFiles, firstAttempt) { + var url = BASE_URL + '/prepare-upload'; + if (pin) { + url += '?pin=' + encodeURIComponent(pin); + } + + makeRequest(url, 'POST', body, function (response) { + if (response.status === 401) { + var newPin = prompt(i18n.enterPin + (firstAttempt ? '' : '\n' + i18n.invalidPin)); + if (!newPin) { + finishWithError(i18n.invalidPin); + return; + } + pin = newPin; + prepareUpload(body, selectedFiles, false); + return; + } + + if (response.status === 429) { + finishWithError(i18n.tooManyAttempts); + return; + } + if (response.status === 403) { finishWithError(i18n.uploadRejected); return;