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, WebSendFile> 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)';
}
}
@@ -37,15 +37,12 @@ class WebSendStateMapper extends ClassMapperBase<WebSendState> {
'autoAccept',
_$autoAccept,
);
static String? _$pin(WebSendState v) => v.pin;
static const Field<WebSendState, String> _f$pin = Field('pin', _$pin);
@override
final MappableFields<WebSendState> 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<WebSendState> {
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<String, WebSendSession>? sessions,
Map<String, WebSendFile>? 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<String, WebSendSession>? sessions,
Map<String, WebSendFile>? 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
+6 -1
View File
@@ -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)';
}
}
@@ -45,6 +45,8 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
'webUpload',
_$webUpload,
);
static String? _$webPin(ServerState v) => v.webPin;
static const Field<ServerState, String> _f$webPin = Field('webPin', _$webPin);
@override
final MappableFields<ServerState> fields = const {
@@ -54,6 +56,7 @@ class ServerStateMapper extends ClassMapperBase<ServerState> {
#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<ServerState> {
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
+44 -37
View File
@@ -62,8 +62,15 @@ class _WebSharePageState extends State<WebSharePage> 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<WebSharePage> with Refena {
port: settings.port,
https: _encrypted,
files: files,
webPin: webPin,
);
} else {
await ref
@@ -80,6 +88,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
port: settings.port,
https: _encrypted,
webUpload: true,
webPin: webPin,
);
}
setState(() {
@@ -169,6 +178,7 @@ class _WebSharePageState extends State<WebSharePage> 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<WebSharePage> 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<WebSharePage> with Refena {
data: urlWithPin,
label: url,
listenIncomingWebSendRequests: _sendMode,
pin: webSendState?.pin,
pin: pin,
),
);
},
@@ -233,7 +243,7 @@ class _WebSharePageState extends State<WebSharePage> with Refena {
builder: (_) => ZoomDialog(
label: url,
listenIncomingWebSendRequests: _sendMode,
pin: webSendState?.pin,
pin: pin,
),
);
},
@@ -354,41 +364,38 @@ class _WebSharePageState extends State<WebSharePage> 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<String>(
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<String>(
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),
),
],
);
},
@@ -79,7 +79,6 @@ class SendController {
),
),
autoAccept: currentWebSendState?.autoAccept ?? server.ref.read(settingsProvider).shareViaLinkAutoAccept,
pin: currentWebSendState?.pin,
);
}
@@ -109,12 +109,15 @@ class ServerService extends Notifier<ServerState?> {
/// 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<ServerState?> 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<ServerState?> {
.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<ServerState?> {
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<ServerState?> {
session: null,
webSendState: webSendState,
webUpload: webUpload,
webPin: webSendState != null || webUpload ? webPin : null,
);
state = newServerState;
@@ -237,9 +241,10 @@ class ServerService extends Notifier<ServerState?> {
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<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].
/// 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({
required String alias,
required int port,
required bool https,
required List<CrossFile> 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<void> setWebSendPin(String? pin) async {
Future<void> 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<ServerState?> {
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,
);
}
+37 -1
View File
@@ -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;