feat: PIN on web receive
CI / format (push) Has been cancelled
CI / test (push) Has been cancelled
CI / packaging (push) Has been cancelled

This commit is contained in:
Tien Do Nam
2026-08-04 03:26:49 +02:00
parent 276bd39b1c
commit a6772e7c16
8 changed files with 113 additions and 61 deletions
@@ -9,17 +9,15 @@ class WebSendState with WebSendStateMappable {
final Map<String, WebSendSession> sessions; // session id -> session data, also includes incoming requests final Map<String, WebSendSession> sessions; // session id -> session data, also includes incoming requests
final Map<String, WebSendFile> files; // file id as key final Map<String, WebSendFile> files; // file id as key
final bool autoAccept; // automatically accept incoming requests 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({ const WebSendState({
required this.sessions, required this.sessions,
required this.files, required this.files,
required this.autoAccept, required this.autoAccept,
required this.pin,
}); });
@override @override
String toString() { String toString() {
return 'WebSendState(sessions: $sessions, files: <${files.keys}>, autoAccept: $autoAccept, pin: $pin)'; return 'WebSendState(sessions: $sessions, files: <${files.keys}>, autoAccept: $autoAccept)';
} }
} }
@@ -37,15 +37,12 @@ class WebSendStateMapper extends ClassMapperBase<WebSendState> {
'autoAccept', 'autoAccept',
_$autoAccept, _$autoAccept,
); );
static String? _$pin(WebSendState v) => v.pin;
static const Field<WebSendState, String> _f$pin = Field('pin', _$pin);
@override @override
final MappableFields<WebSendState> fields = const { final MappableFields<WebSendState> fields = const {
#sessions: _f$sessions, #sessions: _f$sessions,
#files: _f$files, #files: _f$files,
#autoAccept: _f$autoAccept, #autoAccept: _f$autoAccept,
#pin: _f$pin,
}; };
static WebSendState _instantiate(DecodingData data) { static WebSendState _instantiate(DecodingData data) {
@@ -53,7 +50,6 @@ class WebSendStateMapper extends ClassMapperBase<WebSendState> {
sessions: data.dec(_f$sessions), sessions: data.dec(_f$sessions),
files: data.dec(_f$files), files: data.dec(_f$files),
autoAccept: data.dec(_f$autoAccept), autoAccept: data.dec(_f$autoAccept),
pin: data.dec(_f$pin),
); );
} }
@@ -137,7 +133,6 @@ abstract class WebSendStateCopyWith<$R, $In extends WebSendState, $Out>
Map<String, WebSendSession>? sessions, Map<String, WebSendSession>? sessions,
Map<String, WebSendFile>? files, Map<String, WebSendFile>? files,
bool? autoAccept, bool? autoAccept,
String? pin,
}); });
WebSendStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); WebSendStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
} }
@@ -179,13 +174,11 @@ class _WebSendStateCopyWithImpl<$R, $Out>
Map<String, WebSendSession>? sessions, Map<String, WebSendSession>? sessions,
Map<String, WebSendFile>? files, Map<String, WebSendFile>? files,
bool? autoAccept, bool? autoAccept,
Object? pin = $none,
}) => $apply( }) => $apply(
FieldCopyWithData({ FieldCopyWithData({
if (sessions != null) #sessions: sessions, if (sessions != null) #sessions: sessions,
if (files != null) #files: files, if (files != null) #files: files,
if (autoAccept != null) #autoAccept: autoAccept, if (autoAccept != null) #autoAccept: autoAccept,
if (pin != $none) #pin: pin,
}), }),
); );
@override @override
@@ -193,7 +186,6 @@ class _WebSendStateCopyWithImpl<$R, $Out>
sessions: data.get(#sessions, or: $value.sessions), sessions: data.get(#sessions, or: $value.sessions),
files: data.get(#files, or: $value.files), files: data.get(#files, or: $value.files),
autoAccept: data.get(#autoAccept, or: $value.autoAccept), autoAccept: data.get(#autoAccept, or: $value.autoAccept),
pin: data.get(#pin, or: $value.pin),
); );
@override @override
+6 -1
View File
@@ -15,6 +15,10 @@ class ServerState with ServerStateMappable {
/// Whether the upload page is served so web browsers can upload files. /// Whether the upload page is served so web browsers can upload files.
final bool webUpload; 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({ const ServerState({
required this.alias, required this.alias,
required this.port, required this.port,
@@ -22,10 +26,11 @@ class ServerState with ServerStateMappable {
required this.session, required this.session,
required this.webSendState, required this.webSendState,
required this.webUpload, required this.webUpload,
required this.webPin,
}); });
@override @override
String toString() { 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)';
} }
} }
@@ -45,6 +45,8 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
'webUpload', 'webUpload',
_$webUpload, _$webUpload,
); );
static String? _$webPin(ServerState v) => v.webPin;
static const Field<ServerState, String> _f$webPin = Field('webPin', _$webPin);
@override @override
final MappableFields<ServerState> fields = const { final MappableFields<ServerState> fields = const {
@@ -54,6 +56,7 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
#session: _f$session, #session: _f$session,
#webSendState: _f$webSendState, #webSendState: _f$webSendState,
#webUpload: _f$webUpload, #webUpload: _f$webUpload,
#webPin: _f$webPin,
}; };
static ServerState _instantiate(DecodingData data) { static ServerState _instantiate(DecodingData data) {
@@ -64,6 +67,7 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
session: data.dec(_f$session), session: data.dec(_f$session),
webSendState: data.dec(_f$webSendState), webSendState: data.dec(_f$webSendState),
webUpload: data.dec(_f$webUpload), webUpload: data.dec(_f$webUpload),
webPin: data.dec(_f$webPin),
); );
} }
@@ -137,6 +141,7 @@ abstract class ServerStateCopyWith<$R, $In extends ServerState, $Out>
ReceiveSessionState? session, ReceiveSessionState? session,
WebSendState? webSendState, WebSendState? webSendState,
bool? webUpload, bool? webUpload,
String? webPin,
}); });
ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t); ServerStateCopyWith<$R2, $In, $Out2> $chain<$R2, $Out2>(Then<$Out2, $R2> t);
} }
@@ -163,6 +168,7 @@ class _ServerStateCopyWithImpl<$R, $Out>
Object? session = $none, Object? session = $none,
Object? webSendState = $none, Object? webSendState = $none,
bool? webUpload, bool? webUpload,
Object? webPin = $none,
}) => $apply( }) => $apply(
FieldCopyWithData({ FieldCopyWithData({
if (alias != null) #alias: alias, if (alias != null) #alias: alias,
@@ -171,6 +177,7 @@ class _ServerStateCopyWithImpl<$R, $Out>
if (session != $none) #session: session, if (session != $none) #session: session,
if (webSendState != $none) #webSendState: webSendState, if (webSendState != $none) #webSendState: webSendState,
if (webUpload != null) #webUpload: webUpload, if (webUpload != null) #webUpload: webUpload,
if (webPin != $none) #webPin: webPin,
}), }),
); );
@override @override
@@ -181,6 +188,7 @@ class _ServerStateCopyWithImpl<$R, $Out>
session: data.get(#session, or: $value.session), session: data.get(#session, or: $value.session),
webSendState: data.get(#webSendState, or: $value.webSendState), webSendState: data.get(#webSendState, or: $value.webSendState),
webUpload: data.get(#webUpload, or: $value.webUpload), webUpload: data.get(#webUpload, or: $value.webUpload),
webPin: data.get(#webPin, or: $value.webPin),
); );
@override @override
+21 -14
View File
@@ -62,8 +62,15 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
await sleepAsync(500); await sleepAsync(500);
try { try {
final files = widget.files; 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) { 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 await ref
.notifier(serverProvider) .notifier(serverProvider)
.restartServerWithWebSend( .restartServerWithWebSend(
@@ -71,6 +78,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
port: settings.port, port: settings.port,
https: _encrypted, https: _encrypted,
files: files, files: files,
webPin: webPin,
); );
} else { } else {
await ref await ref
@@ -80,6 +88,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
port: settings.port, port: settings.port,
https: _encrypted, https: _encrypted,
webUpload: true, webUpload: true,
webPin: webPin,
); );
} }
setState(() { setState(() {
@@ -169,6 +178,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
} }
final networkState = context.watch(localIpProvider); final networkState = context.watch(localIpProvider);
final settings = context.watch(settingsProvider); final settings = context.watch(settingsProvider);
final pin = serverState.webPin;
return ResponsiveListView( return ResponsiveListView(
padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20), padding: const EdgeInsets.symmetric(horizontal: 15, vertical: 20),
@@ -184,8 +194,8 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
children: [ children: [
...networkState.localIps.map((ip) { ...networkState.localIps.map((ip) {
final url = '${_encrypted ? 'https' : 'http'}://$ip:${serverState.port}'; final url = '${_encrypted ? 'https' : 'http'}://$ip:${serverState.port}';
final urlWithPin = switch (webSendState?.pin) { final urlWithPin = switch (pin) {
String() => '$url/?pin=${Uri.encodeQueryComponent(webSendState!.pin!)}', String() => '$url/?pin=${Uri.encodeQueryComponent(pin)}',
null => url, null => url,
}; };
return Padding( return Padding(
@@ -217,7 +227,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
data: urlWithPin, data: urlWithPin,
label: url, label: url,
listenIncomingWebSendRequests: _sendMode, listenIncomingWebSendRequests: _sendMode,
pin: webSendState?.pin, pin: pin,
), ),
); );
}, },
@@ -233,7 +243,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
builder: (_) => ZoomDialog( builder: (_) => ZoomDialog(
label: url, label: url,
listenIncomingWebSendRequests: _sendMode, listenIncomingWebSendRequests: _sendMode,
pin: webSendState?.pin, pin: pin,
), ),
); );
}, },
@@ -354,18 +364,16 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
), ),
], ],
), ),
if (webSendState != null) ...[
Row( Row(
crossAxisAlignment: CrossAxisAlignment.center, crossAxisAlignment: CrossAxisAlignment.center,
children: [ children: [
Text(t.webSharePage.requirePin, style: Theme.of(context).textTheme.titleMedium), Text(t.webSharePage.requirePin, style: Theme.of(context).textTheme.titleMedium),
const SizedBox(width: 10), const SizedBox(width: 10),
Checkbox( Checkbox(
value: webSendState.pin != null, value: pin != null,
onChanged: (value) async { onChanged: (value) async {
final currentPIN = webSendState.pin; if (pin != null) {
if (currentPIN != null) { await ref.notifier(serverProvider).setWebPin(null);
await ref.notifier(serverProvider).setWebSendPin(null);
} else { } else {
final String? newPin = await showDialog<String>( final String? newPin = await showDialog<String>(
context: context, context: context,
@@ -376,20 +384,19 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
); );
if (newPin != null && newPin.isNotEmpty) { if (newPin != null && newPin.isNotEmpty) {
await ref.notifier(serverProvider).setWebSendPin(newPin); await ref.notifier(serverProvider).setWebPin(newPin);
} }
} }
}, },
), ),
], ],
), ),
if (webSendState.pin != null) if (pin != null)
Text( Text(
t.webSharePage.pinHint(pin: webSendState.pin!), t.webSharePage.pinHint(pin: pin),
style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning), style: Theme.of(context).textTheme.bodyMedium!.copyWith(color: Theme.of(context).colorScheme.warning),
), ),
], ],
],
); );
}, },
), ),
@@ -79,7 +79,6 @@ class SendController {
), ),
), ),
autoAccept: currentWebSendState?.autoAccept ?? server.ref.read(settingsProvider).shareViaLinkAutoAccept, autoAccept: currentWebSendState?.autoAccept ?? server.ref.read(settingsProvider).shareViaLinkAutoAccept,
pin: currentWebSendState?.pin,
); );
} }
@@ -109,12 +109,15 @@ class ServerService extends Notifier<ServerState?> {
/// Starts the server. /// Starts the server.
/// Passing a [webSendState] additionally serves the web send (download) API. /// Passing a [webSendState] additionally serves the web send (download) API.
/// Passing [webUpload] serves the upload page so web browsers can upload files. /// 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<ServerState?> startServer({ Future<ServerState?> startServer({
required String alias, required String alias,
required int port, required int port,
required bool https, required bool https,
WebSendState? webSendState, WebSendState? webSendState,
bool webUpload = false, bool webUpload = false,
String? webPin,
}) async { }) async {
if (state != null) { if (state != null) {
_logger.info('Server already running.'); _logger.info('Server already running.');
@@ -141,7 +144,7 @@ class ServerService extends Notifier<ServerState?> {
.redux(parentIsolateProvider) .redux(parentIsolateProvider)
.dispatchTakeResult( .dispatchTakeResult(
IsolateHttpServerStartAction( IsolateHttpServerStartAction(
pin: settings.receivePin, pin: webUpload ? webPin : settings.receivePin,
verifyChecksums: settings.verifyChecksums, verifyChecksums: settings.verifyChecksums,
web: webSendState != null || webUpload web: webSendState != null || webUpload
? WebParams( ? WebParams(
@@ -150,7 +153,7 @@ class ServerService extends Notifier<ServerState?> {
files: { files: {
for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(), for (final entry in webSendState.files.entries) entry.key: entry.value.file.toRust(),
}, },
pin: webSendState.pin, pin: webPin,
) )
: null, : null,
upload: webUpload, upload: webUpload,
@@ -210,6 +213,7 @@ class ServerService extends Notifier<ServerState?> {
session: null, session: null,
webSendState: webSendState, webSendState: webSendState,
webUpload: webUpload, webUpload: webUpload,
webPin: webSendState != null || webUpload ? webPin : null,
); );
state = newServerState; state = newServerState;
@@ -237,9 +241,10 @@ class ServerService extends Notifier<ServerState?> {
required bool https, required bool https,
WebSendState? webSendState, WebSendState? webSendState,
bool webUpload = false, bool webUpload = false,
String? webPin,
}) async { }) async {
await stopServer(); 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<void> acceptFileRequest(Map<String, String> fileNameMap) async { Future<void> acceptFileRequest(Map<String, String> fileNameMap) async {
@@ -271,23 +276,23 @@ class ServerService extends Notifier<ServerState?> {
} }
/// Restarts the server with web send (the download API) enabled for [files]. /// 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<void> restartServerWithWebSend({ Future<void> restartServerWithWebSend({
required String alias, required String alias,
required int port, required int port,
required bool https, required bool https,
required List<CrossFile> files, required List<CrossFile> files,
String? webPin,
}) async { }) async {
final webSendState = await _sendController.buildWebSendState(files: files); 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. /// The pin is enforced by the Rust server, so the server is restarted.
Future<void> setWebSendPin(String? pin) async { Future<void> setWebPin(String? pin) async {
final current = state; final current = state;
final webSendState = current?.webSendState; if (current == null || (current.webSendState == null && !current.webUpload) || current.webPin == pin) {
if (current == null || webSendState == null || webSendState.pin == pin) {
return; return;
} }
@@ -295,7 +300,9 @@ class ServerService extends Notifier<ServerState?> {
alias: current.alias, alias: current.alias,
port: current.port, port: current.port,
https: current.https, https: current.https,
webSendState: webSendState.copyWith(sessions: {}, pin: pin), webSendState: current.webSendState?.copyWith(sessions: {}),
webUpload: current.webUpload,
webPin: pin,
); );
} }
+37 -1
View File
@@ -68,6 +68,17 @@
var BASE_URL = '/api/localsend/v2'; var BASE_URL = '/api/localsend/v2';
var i18n = {}; 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) { function makeRequest(url, method, body, callback) {
var xhr = new XMLHttpRequest(); var xhr = new XMLHttpRequest();
@@ -154,7 +165,32 @@
files: filesDto 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) { if (response.status === 403) {
finishWithError(i18n.uploadRejected); finishWithError(i18n.uploadRejected);
return; return;