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