immich/mobile/lib/widgets/settings/ssl_client_cert_settings.dart
Jeremy Fleischman de67d22bc0
fix: add missing awaits when changing client certificate (#20189)
I ran into this while testing out
<https://github.com/immich-app/immich/pull/19830>. When I add, change,
or remove a client certificate under Immich's advanced settings, the
change wouldn't take effect until some mysterious point in the future.
For example:

1. Add a client certificate. It doesn't get used.
2. Remove certificate. *Now* the client certificate from step 1) is used.
3. Restart application. Now no client certificate is used.

This all boils down to some missing `await`s. The user would change the
cert, and we'd start asynchronously saving it to the store, and while
the save is still happening, [`HttpSSLOptions` pulls the "old" value out of
`SSLClientCertStoreVal`](https://github.com/immich-app/immich/blob/v1.136.0/mobile/lib/utils/http_ssl_options.dart#L30).

With the appropriate `await`s, this behaves much more sanely.
2025-07-25 00:28:33 -05:00

163 lines
4.6 KiB
Dart

import 'dart:io';
import 'package:easy_localization/easy_localization.dart';
import 'package:file_picker/file_picker.dart';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:immich_mobile/entities/store.entity.dart';
import 'package:immich_mobile/extensions/build_context_extensions.dart';
import 'package:immich_mobile/extensions/theme_extensions.dart';
import 'package:immich_mobile/utils/http_ssl_cert_override.dart';
import 'package:immich_mobile/utils/http_ssl_options.dart';
class SslClientCertSettings extends StatefulWidget {
const SslClientCertSettings({super.key, required this.isLoggedIn});
final bool isLoggedIn;
@override
State<StatefulWidget> createState() => _SslClientCertSettingsState();
}
class _SslClientCertSettingsState extends State<SslClientCertSettings> {
_SslClientCertSettingsState() : isCertExist = SSLClientCertStoreVal.load() != null;
bool isCertExist;
@override
Widget build(BuildContext context) {
return ListTile(
contentPadding: const EdgeInsets.symmetric(horizontal: 20),
horizontalTitleGap: 20,
isThreeLine: true,
title: Text(
"client_cert_title".tr(),
style: context.textTheme.bodyLarge?.copyWith(
fontWeight: FontWeight.w500,
),
),
subtitle: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Text(
"client_cert_subtitle".tr(),
style: context.textTheme.bodyMedium?.copyWith(
color: context.colorScheme.onSurfaceSecondary,
),
),
const SizedBox(
height: 6,
),
Row(
mainAxisSize: MainAxisSize.max,
mainAxisAlignment: MainAxisAlignment.center,
crossAxisAlignment: CrossAxisAlignment.center,
children: [
ElevatedButton(
onPressed: widget.isLoggedIn ? null : () => importCert(context),
child: Text("client_cert_import".tr()),
),
const SizedBox(
width: 15,
),
ElevatedButton(
onPressed: widget.isLoggedIn || !isCertExist ? null : () async => await removeCert(context),
child: Text("remove".tr()),
),
],
),
],
),
);
}
void showMessage(BuildContext context, String message) {
showDialog(
context: context,
builder: (ctx) => AlertDialog(
content: Text(message),
actions: [
TextButton(
onPressed: () => ctx.pop(),
child: Text("client_cert_dialog_msg_confirm".tr()),
),
],
),
);
}
Future<void> storeCert(
BuildContext context,
Uint8List data,
String? password,
) async {
if (password != null && password.isEmpty) {
password = null;
}
final cert = SSLClientCertStoreVal(data, password);
// Test whether the certificate is valid
final isCertValid = HttpSSLCertOverride.setClientCert(
SecurityContext(withTrustedRoots: true),
cert,
);
if (!isCertValid) {
showMessage(context, "client_cert_invalid_msg".tr());
return;
}
await cert.save();
HttpSSLOptions.apply();
setState(
() => isCertExist = true,
);
showMessage(context, "client_cert_import_success_msg".tr());
}
void setPassword(BuildContext context, Uint8List data) {
final password = TextEditingController();
showDialog(
context: context,
barrierDismissible: false,
builder: (ctx) => AlertDialog(
content: TextField(
controller: password,
obscureText: true,
obscuringCharacter: "*",
decoration: InputDecoration(
hintText: "client_cert_enter_password".tr(),
),
),
actions: [
TextButton(
onPressed: () async => {ctx.pop(), await storeCert(context, data, password.text)},
child: Text("client_cert_dialog_msg_confirm".tr()),
),
],
),
);
}
Future<void> importCert(BuildContext ctx) async {
FilePickerResult? res = await FilePicker.platform.pickFiles(
type: FileType.custom,
allowedExtensions: [
'p12',
'pfx',
],
);
if (res != null) {
File file = File(res.files.single.path!);
final bytes = await file.readAsBytes();
setPassword(ctx, bytes);
}
}
Future<void> removeCert(BuildContext context) async {
await SSLClientCertStoreVal.delete();
HttpSSLOptions.apply();
setState(
() => isCertExist = false,
);
showMessage(context, "client_cert_remove_msg".tr());
}
}