diff --git a/app/lib/core/filesystem.dart b/app/lib/core/filesystem.dart index bfb30e3a..6e7f92f3 100644 --- a/app/lib/core/filesystem.dart +++ b/app/lib/core/filesystem.dart @@ -20,6 +20,8 @@ import 'dart:async'; import 'dart:io'; +import 'package:collection/collection.dart'; +import 'package:nullability/nullability.dart'; import 'package:path/path.dart' as p; import 'package:path_provider/path_provider.dart' as path_provider; import 'package:sqlite3/sqlite3.dart'; @@ -64,6 +66,21 @@ class _Filesystem { return fs.writeStartupProfile(profilesDir, profile, flush: true); } + Future clearMozillaProfileCache(String profileId) { + return fs.clearMozillaProfileCache(profileId); + } + + Future checkForDuplicateMozillaProfile(UuidValue profile) async { + final duplicates = await fs + .getProfilesWithDuplicateMozillaProfiles(profilesDir) + .then((dirs) => dirs.map((dir) => dir.path).toList()); + final profileDir = fs.getProfileDir(profilesDir, profile).path; + + return duplicates + .firstWhereOrNull((dir) => p.isWithin(profileDir, dir)) + .mapNotNull((dir) => p.basename(dir)); + } + Future _linkMozillaDir(Directory filesDir) async { final mozillaDir = Directory(p.join(selectedProfileDir.path, 'mozilla')); await mozillaDir.create(); diff --git a/app/lib/core/routing/routes.browser.dart b/app/lib/core/routing/routes.browser.dart index d5c7b0ac..f541ffa8 100644 --- a/app/lib/core/routing/routes.browser.dart +++ b/app/lib/core/routing/routes.browser.dart @@ -73,6 +73,18 @@ part of 'routes.dart'; path: 'profiles', routes: [ TypedGoRoute(name: 'ProfileEditScreen', path: 'edit'), + TypedGoRoute( + name: 'ProfileBackupListRoute', + path: 'backup_list', + ), + TypedGoRoute( + name: 'RestoreProfileRoute', + path: 'restore', + ), + TypedGoRoute( + name: 'BackupProfileRoute', + path: 'backup', + ), TypedGoRoute( name: 'CreateProfileRoute', path: 'create', @@ -303,6 +315,37 @@ class EditProfileRoute extends GoRouteData with $EditProfileRoute { } } +class BackupProfileRoute extends GoRouteData with $BackupProfileRoute { + final String profile; + + const BackupProfileRoute({required this.profile}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return ProfileBackupScreen( + profile: Profile.fromJson(jsonDecode(profile) as Map), + ); + } +} + +class RestoreProfileRoute extends GoRouteData with $RestoreProfileRoute { + final String backupFilePath; + + const RestoreProfileRoute({required this.backupFilePath}); + + @override + Widget build(BuildContext context, GoRouterState state) { + return ProfileRestoreScreen(backupFile: File(backupFilePath)); + } +} + +class ProfileBackupListRoute extends GoRouteData with $ProfileBackupListRoute { + @override + Widget build(BuildContext context, GoRouterState state) { + return const ProfileBackupListScreen(); + } +} + class BookmarkListRoute extends GoRouteData with $BookmarkListRoute { final String entryGuid; diff --git a/app/lib/core/routing/routes.dart b/app/lib/core/routing/routes.dart index b8c3dea3..0d2730da 100644 --- a/app/lib/core/routing/routes.dart +++ b/app/lib/core/routing/routes.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'dart:convert'; +import 'dart:io'; import 'package:flutter/material.dart'; import 'package:flutter_mozilla_components/flutter_mozilla_components.dart'; @@ -64,8 +65,11 @@ import 'package:weblibre/features/settings/presentation/screens/web_engine_harde import 'package:weblibre/features/settings/presentation/screens/web_engine_settings.dart'; import 'package:weblibre/features/tor/presentation/screens/tor_proxy.dart'; import 'package:weblibre/features/user/domain/presentation/dialogs/select_profile.dart'; +import 'package:weblibre/features/user/domain/presentation/screens/profile_backup.dart'; +import 'package:weblibre/features/user/domain/presentation/screens/profile_backup_list.dart'; import 'package:weblibre/features/user/domain/presentation/screens/profile_edit.dart'; import 'package:weblibre/features/user/domain/presentation/screens/profile_list.dart'; +import 'package:weblibre/features/user/domain/presentation/screens/profile_restore.dart'; import 'package:weblibre/features/web_feed/presentation/add_feed_dialog.dart'; import 'package:weblibre/features/web_feed/presentation/screens/feed_article.dart'; import 'package:weblibre/features/web_feed/presentation/screens/feed_article_list.dart'; diff --git a/app/lib/core/routing/routes.g.dart b/app/lib/core/routing/routes.g.dart index b824578e..8ecc7dbf 100644 --- a/app/lib/core/routing/routes.g.dart +++ b/app/lib/core/routing/routes.g.dart @@ -457,6 +457,21 @@ RouteBase get $browserRoute => GoRouteData.$route( name: 'ProfileEditScreen', factory: $EditProfileRoute._fromState, ), + GoRouteData.$route( + path: 'backup_list', + name: 'ProfileBackupListRoute', + factory: $ProfileBackupListRoute._fromState, + ), + GoRouteData.$route( + path: 'restore', + name: 'RestoreProfileRoute', + factory: $RestoreProfileRoute._fromState, + ), + GoRouteData.$route( + path: 'backup', + name: 'BackupProfileRoute', + factory: $BackupProfileRoute._fromState, + ), GoRouteData.$route( path: 'create', name: 'CreateProfileRoute', @@ -880,6 +895,81 @@ mixin $EditProfileRoute on GoRouteData { void replace(BuildContext context) => context.replace(location); } +mixin $ProfileBackupListRoute on GoRouteData { + static ProfileBackupListRoute _fromState(GoRouterState state) => + ProfileBackupListRoute(); + + @override + String get location => GoRouteData.$location('/browser/profiles/backup_list'); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin $RestoreProfileRoute on GoRouteData { + static RestoreProfileRoute _fromState(GoRouterState state) => + RestoreProfileRoute( + backupFilePath: state.uri.queryParameters['backup-file-path']!, + ); + + RestoreProfileRoute get _self => this as RestoreProfileRoute; + + @override + String get location => GoRouteData.$location( + '/browser/profiles/restore', + queryParams: {'backup-file-path': _self.backupFilePath}, + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + +mixin $BackupProfileRoute on GoRouteData { + static BackupProfileRoute _fromState(GoRouterState state) => + BackupProfileRoute(profile: state.uri.queryParameters['profile']!); + + BackupProfileRoute get _self => this as BackupProfileRoute; + + @override + String get location => GoRouteData.$location( + '/browser/profiles/backup', + queryParams: {'profile': _self.profile}, + ); + + @override + void go(BuildContext context) => context.go(location); + + @override + Future push(BuildContext context) => context.push(location); + + @override + void pushReplacement(BuildContext context) => + context.pushReplacement(location); + + @override + void replace(BuildContext context) => context.replace(location); +} + mixin $CreateProfileRoute on GoRouteData { static CreateProfileRoute _fromState(GoRouterState state) => CreateProfileRoute(); diff --git a/app/lib/domain/entities/profile.dart b/app/lib/domain/entities/profile.dart index 2dde7e36..6cf2470e 100644 --- a/app/lib/domain/entities/profile.dart +++ b/app/lib/domain/entities/profile.dart @@ -34,10 +34,12 @@ class Profile with FastEquatable { late final uuidValue = UuidValue.fromString(id); + static String getNewProfileId() => uuid.v7(); + Profile({required this.id, required this.name}); factory Profile.create({required String name}) { - return Profile(id: uuid.v7(), name: name); + return Profile(id: getNewProfileId(), name: name); } @override diff --git a/app/lib/extensions/iterable.dart b/app/lib/extensions/iterable.dart new file mode 100644 index 00000000..88b9bd46 --- /dev/null +++ b/app/lib/extensions/iterable.dart @@ -0,0 +1,13 @@ +extension UniqueItems on Iterable { + Iterable findDuplicates() sync* { + final seen = {}; + + for (final item in this) { + if (seen.contains(item)) { + yield item; + } else { + seen.add(item); + } + } + } +} diff --git a/app/lib/features/user/domain/presentation/dialogs/select_profile.dart b/app/lib/features/user/domain/presentation/dialogs/select_profile.dart index 33b9e96f..8149d7a8 100644 --- a/app/lib/features/user/domain/presentation/dialogs/select_profile.dart +++ b/app/lib/features/user/domain/presentation/dialogs/select_profile.dart @@ -18,6 +18,7 @@ * along with this program. If not, see . */ import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/filesystem.dart'; @@ -47,36 +48,74 @@ class SelectProfileDialog extends HookConsumerWidget { title: Text(profile.name), subtitle: isSelected ? const Text('Active') : null, onTap: () async { - final result = await showDialog( - context: context, - builder: (context) => AlertDialog( - icon: const Icon(Icons.warning), - title: const Text('Switch User'), - content: Text( - "Switching to User '${profile.name}' will require a restart of the Browser", - ), - actions: [ - TextButton( - onPressed: () { - context.pop(false); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - context.pop(true); - }, - child: const Text('Switch Profile'), - ), - ], - ), - ); + final duplicateMozillaProfile = await filesystem + .checkForDuplicateMozillaProfile(profile.uuidValue); - if (result == true) { - await ref - .read(profileRepositoryProvider.notifier) - .switchProfile(profile.id); - await exitApp(ref.container); + if (context.mounted) { + final result = await showDialog<(bool, bool)>( + context: context, + builder: (context) => HookBuilder( + builder: (context) { + final clearCache = useState(false); + + return AlertDialog( + icon: const Icon(Icons.warning), + title: const Text('Switch User'), + content: Column( + mainAxisSize: MainAxisSize.min, + children: [ + Text( + "Switching to User '${profile.name}' will require a restart of the Browser.", + style: const TextStyle( + fontWeight: FontWeight.bold, + ), + ), + const SizedBox(height: 8), + if (duplicateMozillaProfile != null) + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: clearCache.value, + title: const Text('Clear Shared Cache'), + subtitle: const Text( + 'This User has been created based on an exisiting Mozilla Profile Identifier. Clearing cache will affect all linked accounts.', + ), + onChanged: (value) { + clearCache.value = value; + }, + ), + ], + ), + actions: [ + TextButton( + onPressed: () { + context.pop((false, false)); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + context.pop((true, clearCache.value)); + }, + child: const Text('Switch Profile'), + ), + ], + ); + }, + ), + ); + + if (result?.$1 == true) { + if (duplicateMozillaProfile != null && result?.$2 == true) { + await filesystem.clearMozillaProfileCache( + duplicateMozillaProfile, + ); + } + + await ref + .read(profileRepositoryProvider.notifier) + .switchProfile(profile.id); + await exitApp(ref.container); + } } }, ); diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup.dart b/app/lib/features/user/domain/presentation/screens/profile_backup.dart new file mode 100644 index 00000000..c01465bc --- /dev/null +++ b/app/lib/features/user/domain/presentation/screens/profile_backup.dart @@ -0,0 +1,187 @@ +import 'package:fancy_password_field/fancy_password_field.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/domain/entities/profile.dart'; +import 'package:weblibre/features/user/domain/services/user_backup.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +class ProfileBackupScreen extends HookConsumerWidget { + final Profile profile; + + const ProfileBackupScreen({required this.profile}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + + final passwordTextController = useTextEditingController(); + final passwordController = useMemoized(() => FancyPasswordController()); + + final integrityVerification = useState(true); + final skipPasswordConfirmation = useState(false); + + final backupFuture = useState?>(null); + final backupState = useFuture(backupFuture.value); + + useEffect(() { + if (backupState.hasError) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showErrorMessage(context, backupState.error!.toString()); + }); + } else if (backupState.hasData) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showInfoMessage(context, 'Backup created successfully'); + ProfileListRoute().go(context); + }); + } + + return null; + }, [backupState.hasError, backupState.hasData, backupState.error]); + + final disableInteraction = + backupState.connectionState == ConnectionState.waiting; + + return Scaffold( + appBar: AppBar(title: const Text('Create Backup')), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Form( + key: formKey, + child: ListView( + children: [ + FancyPasswordField( + controller: passwordTextController, + enabled: !disableInteraction, + passwordController: passwordController, + enableSuggestions: false, + autocorrect: false, + enableIMEPersonalizedLearning: false, + keyboardType: TextInputType.visiblePassword, + decoration: const InputDecoration( + labelText: 'Password', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + validationRules: {MinCharactersValidationRule(5)}, + validator: (value) { + //Make sure since onChange is sometimes unreliable + passwordController.onChange(value ?? ''); + + return passwordController.areAllRulesValidated + ? null + : 'Not Validated'; + }, + ), + const SizedBox(height: 16), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: integrityVerification.value, + onChanged: disableInteraction + ? null + : (value) { + integrityVerification.value = value; + }, + title: const Text('Verify Backup Integrity'), + subtitle: const Text( + 'Automatically check that backups are complete and restorable', + ), + ), + ExpansionTile( + enabled: !disableInteraction, + childrenPadding: EdgeInsets.zero, + tilePadding: EdgeInsets.zero, + title: const Text('Advanced'), + children: [ + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: skipPasswordConfirmation.value, + onChanged: disableInteraction + ? null + : (value) { + skipPasswordConfirmation.value = value; + }, + title: const Text('Skip Password Confirmation Prompt'), + ), + ], + ), + const SizedBox(height: 16), + if (disableInteraction) + const Column( + children: [ + LinearProgressIndicator(), + Text('Creating Backup'), + ], + ) + else + FilledButton.icon( + icon: const Icon(MdiIcons.safe), + onPressed: () async { + if (formKey.currentState?.validate() ?? false) { + if (!skipPasswordConfirmation.value) { + final confirmation = await showDialog( + context: context, + builder: (context) { + final controller = TextEditingController(); + + return AlertDialog( + title: const Text('Password Confirmation'), + content: TextField( + controller: controller, + enableSuggestions: false, + autocorrect: false, + enableIMEPersonalizedLearning: false, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + floatingLabelBehavior: + FloatingLabelBehavior.always, + ), + ), + actions: [ + TextButton( + onPressed: () { + Navigator.of(context).pop(); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.of(context).pop(controller.text); + }, + child: const Text('Confirm'), + ), + ], + ); + }, + ); + + if (confirmation != passwordTextController.text) { + if (context.mounted) { + showErrorMessage(context, 'Passwords do not match'); + } + + return; + } + } + + backupFuture.value = ref + .read(userBackupServiceProvider.notifier) + .createUserBackup( + profile, + password: passwordTextController.text, + integrityCheck: integrityVerification.value, + ); + } + }, + label: const Text('Backup'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart new file mode 100644 index 00000000..7d5fb0e0 --- /dev/null +++ b/app/lib/features/user/domain/presentation/screens/profile_backup_list.dart @@ -0,0 +1,79 @@ +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:path/path.dart' as p; +import 'package:weblibre/core/providers/format.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/user/domain/providers.dart'; +import 'package:weblibre/features/user/domain/services/user_backup.dart'; +import 'package:weblibre/presentation/widgets/failure_widget.dart'; + +final _filenamePattern = RegExp( + r'^backup_(?.+?)_(?\d{4}-\d{2}-\d{2}_\d{6})\.weblibre$', +); + +class ProfileBackupListScreen extends HookConsumerWidget { + const ProfileBackupListScreen(); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final backupListAsync = ref.watch(backupListProvider); + + return Scaffold( + appBar: AppBar(title: const Text('Backups')), + body: backupListAsync.when( + data: (backupList) { + return ListView.builder( + itemCount: backupList.length, + itemBuilder: (context, index) { + final file = backupList[index]; + final match = _filenamePattern.firstMatch(p.basename(file.path)); + + if (match != null) { + final profileName = match.group(1)!; + final datePart = match.group(2)!; + + // Reparse into DateTime + final dateTime = UserBackupService.dateFormatter.decode( + datePart, + ); + + return ListTile( + key: ValueKey(file.path), + title: Text(profileName), + subtitle: Text( + ref + .read(formatProvider.notifier) + .fullDateTimeWithTimezone(dateTime), + ), + onTap: () async { + await RestoreProfileRoute( + backupFilePath: file.path, + ).push(context); + }, + ); + } else { + return ListTile( + key: ValueKey(file.path), + title: Text(p.basename(file.path)), + onTap: () async { + await RestoreProfileRoute( + backupFilePath: file.path, + ).push(context); + }, + ); + } + }, + ); + }, + error: (error, stackTrace) => FailureWidget( + title: 'Failed to get backups', + exception: error, + onRetry: () { + ref.invalidate(backupListProvider); + }, + ), + loading: () => const Center(child: CircularProgressIndicator()), + ), + ); + } +} diff --git a/app/lib/features/user/domain/presentation/screens/profile_edit.dart b/app/lib/features/user/domain/presentation/screens/profile_edit.dart index f56dcbc4..396f4e55 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_edit.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_edit.dart @@ -17,10 +17,14 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:convert'; + import 'package:flutter/material.dart'; import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:go_router/go_router.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; import 'package:weblibre/domain/entities/profile.dart'; import 'package:weblibre/features/user/domain/repositories/profile.dart'; import 'package:weblibre/utils/form_validators.dart'; @@ -81,62 +85,76 @@ class ProfileEditScreen extends HookConsumerWidget { label: Text('Name'), floatingLabelBehavior: FloatingLabelBehavior.always, ), - validator: validateRequired, + validator: validateProfileName, ), const SizedBox(height: 16), - if (profile != null) + if (profile != null) ...[ SizedBox( width: double.infinity, child: OutlinedButton.icon( - style: OutlinedButton.styleFrom( - side: BorderSide( - color: Theme.of(context).colorScheme.error, - ), - foregroundColor: Theme.of(context).colorScheme.error, - iconColor: Theme.of(context).colorScheme.error, - ), - label: const Text('Delete'), - icon: const Icon(Icons.delete), + label: const Text('Backup'), + icon: const Icon(MdiIcons.safe), onPressed: () async { - final result = await showDialog( - context: context, - builder: (BuildContext context) { - return AlertDialog( - icon: const Icon(Icons.warning), - title: const Text('Delete User'), - content: const Text( - 'Are you sure you want to delete this User including all data?', - ), - actions: [ - TextButton( - onPressed: () { - Navigator.pop(context, false); - }, - child: const Text('Cancel'), - ), - TextButton( - onPressed: () { - Navigator.pop(context, true); - }, - child: const Text('Delete'), - ), - ], - ); - }, - ); - - if (result == true) { - await ref - .read(profileRepositoryProvider.notifier) - .deleteProfile(profile!.uuidValue.uuid); - - if (context.mounted) { - context.pop(); - } - } + await BackupProfileRoute( + profile: jsonEncode(profile!.toJson()), + ).push(context); }, ), ), + ], + const SizedBox(height: 16), + SizedBox( + width: double.infinity, + child: OutlinedButton.icon( + style: OutlinedButton.styleFrom( + side: BorderSide( + color: Theme.of(context).colorScheme.error, + ), + foregroundColor: Theme.of(context).colorScheme.error, + iconColor: Theme.of(context).colorScheme.error, + ), + label: const Text('Delete'), + icon: const Icon(Icons.delete), + onPressed: () async { + final result = await showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.warning), + title: const Text('Delete User'), + content: const Text( + 'Are you sure you want to delete this User including all data?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Delete'), + ), + ], + ); + }, + ); + + if (result == true) { + await ref + .read(profileRepositoryProvider.notifier) + .deleteProfile(profile!.uuidValue.uuid); + + if (context.mounted) { + context.pop(); + } + } + }, + ), + ), ], ), ), diff --git a/app/lib/features/user/domain/presentation/screens/profile_list.dart b/app/lib/features/user/domain/presentation/screens/profile_list.dart index c2d59a9a..50138de2 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_list.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_list.dart @@ -20,6 +20,7 @@ import 'dart:convert'; import 'package:flutter/material.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import 'package:weblibre/core/filesystem.dart'; import 'package:weblibre/core/routing/routes.dart'; @@ -34,7 +35,17 @@ class ProfileListScreen extends HookConsumerWidget { final usersAsync = ref.watch(profileRepositoryProvider); return Scaffold( - appBar: AppBar(title: const Text('Users')), + appBar: AppBar( + title: const Text('Users'), + actions: [ + IconButton( + onPressed: () async { + await ProfileBackupListRoute().push(context); + }, + icon: const Icon(MdiIcons.backupRestore), + ), + ], + ), body: usersAsync.when( skipLoadingOnReload: true, data: (profiles) => ListView.builder( diff --git a/app/lib/features/user/domain/presentation/screens/profile_restore.dart b/app/lib/features/user/domain/presentation/screens/profile_restore.dart new file mode 100644 index 00000000..1d1806ba --- /dev/null +++ b/app/lib/features/user/domain/presentation/screens/profile_restore.dart @@ -0,0 +1,183 @@ +import 'dart:io'; + +import 'package:flutter/material.dart'; +import 'package:flutter_hooks/flutter_hooks.dart'; +import 'package:flutter_material_design_icons/flutter_material_design_icons.dart'; +import 'package:go_router/go_router.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:weblibre/core/routing/routes.dart'; +import 'package:weblibre/features/user/domain/services/user_backup.dart'; +import 'package:weblibre/utils/form_validators.dart'; +import 'package:weblibre/utils/ui_helper.dart'; + +enum RestoreTarget { createOrOverride, createNew } + +class ProfileRestoreScreen extends HookConsumerWidget { + final File backupFile; + + const ProfileRestoreScreen({super.key, required this.backupFile}); + + @override + Widget build(BuildContext context, WidgetRef ref) { + final formKey = useMemoized(() => GlobalKey()); + + final passwordTextController = useTextEditingController(); + final nameTextController = useTextEditingController(); + + final restoreFuture = useState?>(null); + final restoreState = useFuture(restoreFuture.value); + + final restoreTarget = useState(RestoreTarget.createNew); + + useEffect(() { + if (restoreState.hasError) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showErrorMessage(context, restoreState.error!.toString()); + }); + } else if (restoreState.hasData) { + WidgetsBinding.instance.addPostFrameCallback((_) { + showInfoMessage(context, 'Backup restored successfully'); + ProfileListRoute().go(context); + }); + } + + return null; + }, [restoreState.hasError, restoreState.hasData, restoreState.error]); + + final disableInteraction = + restoreState.connectionState == ConnectionState.waiting; + + return Scaffold( + appBar: AppBar(title: const Text('Restore Backup')), + body: Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8), + child: Form( + key: formKey, + child: ListView( + children: [ + TextFormField( + controller: passwordTextController, + enabled: !disableInteraction, + enableSuggestions: false, + autocorrect: false, + enableIMEPersonalizedLearning: false, + keyboardType: TextInputType.visiblePassword, + obscureText: true, + decoration: const InputDecoration( + labelText: 'Password', + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + validator: (value) { + return validateRequired(value, message: 'Password required'); + }, + ), + const SizedBox(height: 16), + RadioGroup( + groupValue: restoreTarget.value, + onChanged: (value) { + if (value != null) { + restoreTarget.value = value; + } + }, + child: Column( + children: [ + RadioListTile( + enabled: !disableInteraction, + value: RestoreTarget.createNew, + title: const Text('Create New User'), + subtitle: const Text('Restore backup as a new user'), + ), + RadioListTile( + enabled: !disableInteraction, + value: RestoreTarget.createOrOverride, + title: const Text('Restore & Replace'), + subtitle: const Text( + 'Restore backup and overwrite existing user if present', + ), + ), + ], + ), + ), + if (restoreTarget.value == RestoreTarget.createNew) + TextFormField( + controller: nameTextController, + enabled: !disableInteraction, + decoration: const InputDecoration( + label: Text('Name'), + floatingLabelBehavior: FloatingLabelBehavior.always, + ), + validator: validateProfileName, + ), + const SizedBox(height: 16), + if (disableInteraction) + const Column( + children: [ + LinearProgressIndicator(), + Text('Restoring Backup'), + ], + ) + else + FilledButton.icon( + icon: const Icon(MdiIcons.backupRestore), + onPressed: () { + if (formKey.currentState?.validate() ?? false) { + restoreFuture.value = switch (restoreTarget.value) { + RestoreTarget.createOrOverride => + ref + .read(userBackupServiceProvider.notifier) + .restoreAndCreateOrOverride( + backupFile, + password: passwordTextController.text, + confirmOverrideCallback: () { + if (context.mounted) { + return showDialog( + context: context, + builder: (BuildContext context) { + return AlertDialog( + icon: const Icon(Icons.warning), + title: const Text('Override User'), + content: const Text( + 'Are you sure you want to override the exisiting User?', + ), + actions: [ + TextButton( + onPressed: () { + Navigator.pop(context, false); + }, + child: const Text('Cancel'), + ), + TextButton( + onPressed: () { + Navigator.pop(context, true); + }, + child: const Text('Override'), + ), + ], + ); + }, + ); + } else { + throw Exception('Override failed'); + } + }, + ), + RestoreTarget.createNew => + ref + .read(userBackupServiceProvider.notifier) + .restoreAndCreateNew( + backupFile, + profileName: nameTextController.text, + password: passwordTextController.text, + ), + }; + } + }, + label: const Text('Restore'), + ), + ], + ), + ), + ), + ); + } +} diff --git a/app/lib/features/user/domain/providers.dart b/app/lib/features/user/domain/providers.dart index 57eb343a..c7948a69 100644 --- a/app/lib/features/user/domain/providers.dart +++ b/app/lib/features/user/domain/providers.dart @@ -17,6 +17,8 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:io'; + import 'package:exceptions/exceptions.dart'; import 'package:nullability/nullability.dart'; import 'package:riverpod/riverpod.dart'; @@ -29,6 +31,7 @@ import 'package:weblibre/features/user/domain/repositories/engine_settings.dart' import 'package:weblibre/features/user/domain/repositories/general_settings.dart'; import 'package:weblibre/features/user/domain/repositories/profile.dart'; import 'package:weblibre/features/user/domain/services/fingerprinting.dart'; +import 'package:weblibre/features/user/domain/services/user_backup.dart'; part 'providers.g.dart'; @@ -73,3 +76,11 @@ Future selectedProfile(Ref ref) async { final profiles = await ref.watch(profileRepositoryProvider.future); return profiles.firstWhere((p) => p.uuidValue == filesystem.selectedProfile); } + +@Riverpod() +Future> backupList(Ref ref) { + return ref + .watch(userBackupServiceProvider.notifier) + .getBackupListStream() + .toList(); +} diff --git a/app/lib/features/user/domain/providers.g.dart b/app/lib/features/user/domain/providers.g.dart index 5273b688..101f0280 100644 --- a/app/lib/features/user/domain/providers.g.dart +++ b/app/lib/features/user/domain/providers.g.dart @@ -160,3 +160,41 @@ final class SelectedProfileProvider } String _$selectedProfileHash() => r'c703cad8f30abb4f5f42db0119756ee6791ac477'; + +@ProviderFor(backupList) +const backupListProvider = BackupListProvider._(); + +final class BackupListProvider + extends + $FunctionalProvider< + AsyncValue>, + List, + FutureOr> + > + with $FutureModifier>, $FutureProvider> { + const BackupListProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'backupListProvider', + isAutoDispose: true, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$backupListHash(); + + @$internal + @override + $FutureProviderElement> $createElement($ProviderPointer pointer) => + $FutureProviderElement(pointer); + + @override + FutureOr> create(Ref ref) { + return backupList(ref); + } +} + +String _$backupListHash() => r'6fdfbb5293df11aa37ed74f4fcf9bd78591d770f'; diff --git a/app/lib/features/user/domain/services/user_backup.dart b/app/lib/features/user/domain/services/user_backup.dart new file mode 100644 index 00000000..6eb31262 --- /dev/null +++ b/app/lib/features/user/domain/services/user_backup.dart @@ -0,0 +1,154 @@ +import 'dart:io'; + +import 'package:convert/convert.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; +import 'package:riverpod_annotation/riverpod_annotation.dart'; +import 'package:secure_archive/secure_archive.dart'; +import 'package:weblibre/core/filesystem.dart'; +import 'package:weblibre/domain/entities/profile.dart'; +import 'package:weblibre/features/user/domain/repositories/profile.dart'; + +part 'user_backup.g.dart'; + +@Riverpod(keepAlive: true) +class UserBackupService extends _$UserBackupService { + static final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss'); + + Future getBackupDirectory() async { + return Directory( + p.join( + await getExternalStorageDirectory().then( + (dir) => Directory( + dir!.path.replaceFirst('/data/', '/media/'), + ).parent.path, + ), + 'Backup', + ), + ); + } + + Stream getBackupListStream() async* { + final backupDirectory = await getBackupDirectory(); + + await for (final entity in backupDirectory.list(recursive: true)) { + if (entity is File) { + yield entity; + } + } + } + + Future createUserBackup( + Profile profile, { + required String password, + required bool integrityCheck, + }) async { + final timestamp = dateFormatter.encode(DateTime.now()); + + final outputFile = File( + p.join( + await getBackupDirectory().then((dir) => dir.path), + 'backup_${profile.name}_$timestamp.weblibre', + ), + ); + + await outputFile.parent.create(recursive: true); + + final backup = SecureArchivePack( + outputFile: outputFile, + sourceDirectory: filesystem.getProfileDir(profile.uuidValue), + argon2Params: Argon2Params.memoryConstrained(), + ); + + await backup.pack(password, integrityCheck: integrityCheck); + + return true; + } + + Future restoreAndCreateNew( + File backupFile, { + required String profileName, + required String password, + }) async { + final outputDirectory = Directory( + p.join(filesystem.profilesDir.path, p.basename(backupFile.path)), + ); + + try { + final backup = SecureArchiveUnpack( + inputFile: backupFile, + outputDirectory: outputDirectory, + argon2Params: Argon2Params.memoryConstrained(), + ); + await backup.unpack(password).then((_) async { + final newProfile = Profile.create(name: profileName); + final newPath = filesystem.getProfileDir(newProfile.uuidValue); + + await outputDirectory.rename(newPath.path); + await filesystem.updateProfileMetadata(newProfile); + }); + + ref.invalidate(profileRepositoryProvider); + return true; + } finally { + try { + if (await outputDirectory.exists()) { + await outputDirectory.delete(recursive: true); + } + } catch (_) { + // Ignore cleanup errors + } + } + } + + Future restoreAndCreateOrOverride( + File backupFile, { + required String password, + required FutureOr Function() confirmOverrideCallback, + }) async { + final outputDirectory = Directory( + p.join(filesystem.profilesDir.path, p.basename(backupFile.path)), + ); + + try { + final backup = SecureArchiveUnpack( + inputFile: backupFile, + outputDirectory: outputDirectory, + argon2Params: Argon2Params.memoryConstrained(), + ); + await backup.unpack(password).then((_) async { + final existingProfile = await filesystem.readProfileMetadata( + outputDirectory, + ); + if (existingProfile != null) { + if (existingProfile.uuidValue == filesystem.selectedProfile) { + throw Exception( + 'Unable to override active User, please switch to another User and try again', + ); + } + + final profileDir = filesystem.getProfileDir( + existingProfile.uuidValue, + ); + + if (await profileDir.exists()) { + final result = await confirmOverrideCallback(); + + if (result == true) { + await profileDir.delete(recursive: true); + await outputDirectory.rename(profileDir.path); + } + } + } + }); + + ref.invalidate(profileRepositoryProvider); + return true; + } finally { + await outputDirectory.delete(recursive: true); + } + } + + @override + void build() {} +} diff --git a/app/lib/features/user/domain/services/user_backup.g.dart b/app/lib/features/user/domain/services/user_backup.g.dart new file mode 100644 index 00000000..57b0fc72 --- /dev/null +++ b/app/lib/features/user/domain/services/user_backup.g.dart @@ -0,0 +1,63 @@ +// GENERATED CODE - DO NOT MODIFY BY HAND + +part of 'user_backup.dart'; + +// ************************************************************************** +// RiverpodGenerator +// ************************************************************************** + +// GENERATED CODE - DO NOT MODIFY BY HAND +// ignore_for_file: type=lint, type=warning + +@ProviderFor(UserBackupService) +const userBackupServiceProvider = UserBackupServiceProvider._(); + +final class UserBackupServiceProvider + extends $NotifierProvider { + const UserBackupServiceProvider._() + : super( + from: null, + argument: null, + retry: null, + name: r'userBackupServiceProvider', + isAutoDispose: false, + dependencies: null, + $allTransitiveDependencies: null, + ); + + @override + String debugGetCreateSourceHash() => _$userBackupServiceHash(); + + @$internal + @override + UserBackupService create() => UserBackupService(); + + /// {@macro riverpod.override_with_value} + Override overrideWithValue(void value) { + return $ProviderOverride( + origin: this, + providerOverride: $SyncValueProvider(value), + ); + } +} + +String _$userBackupServiceHash() => r'358e4c58703b14cff767184a4f658f7eac0454f6'; + +abstract class _$UserBackupService extends $Notifier { + void build(); + @$mustCallSuper + @override + void runBuild() { + build(); + final ref = this.ref as $Ref; + final element = + ref.element + as $ClassProviderElement< + AnyNotifier, + void, + Object?, + Object? + >; + element.handleValue(ref, null); + } +} diff --git a/app/lib/utils/filesystem.dart b/app/lib/utils/filesystem.dart index a13236b3..cd4e2d5a 100644 --- a/app/lib/utils/filesystem.dart +++ b/app/lib/utils/filesystem.dart @@ -22,9 +22,11 @@ import 'dart:convert'; import 'dart:io'; import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; import 'package:uuid/uuid_value.dart'; import 'package:weblibre/core/logger.dart'; import 'package:weblibre/domain/entities/profile.dart'; +import 'package:weblibre/extensions/iterable.dart'; const profilesDirName = 'weblibre_profiles'; const profileDirPrefix = 'profile-'; @@ -42,10 +44,54 @@ final profileTransformer = }, ); +final profileMozillaDirectoryTransformer = + StreamTransformer.fromHandlers( + handleData: (entity, sink) { + final mozillaDir = Directory(p.join(entity.path, 'mozilla')); + + for (final entity in mozillaDir.listSync()) { + if (entity is Directory) { + final profile = p.basename(entity.path); + if (profile.endsWith('.default')) { + sink.add(entity); + } + } + } + }, + ); + Future> getAvailableProfileDirectories(Directory profilesDir) { return profilesDir.list().transform(profileTransformer).toList(); } +Future clearMozillaProfileCache(String profileId) async { + final cacheDir = await getApplicationCacheDirectory(); + final mozillaCacheDir = Directory(p.join(cacheDir.path, profileId)); + + if (await mozillaCacheDir.exists()) { + await mozillaCacheDir.delete(recursive: true); + } +} + +Future> getProfilesWithDuplicateMozillaProfiles( + Directory profilesDir, +) async { + final mozillaProfileDirs = await profilesDir + .list() + .transform(profileTransformer) + .transform(profileMozillaDirectoryTransformer) + .toList(); + + final duplicates = mozillaProfileDirs + .map((dir) => p.basename(dir.path)) + .findDuplicates() + .toSet(); + + return mozillaProfileDirs + .where((dir) => duplicates.contains(p.basename(dir.path))) + .toList(); +} + Future readStartupProfile(Directory dir) async { final file = File(p.join(dir.path, _startupProfileFileName)); diff --git a/app/lib/utils/form_validators.dart b/app/lib/utils/form_validators.dart index db281b61..e5eea8b2 100644 --- a/app/lib/utils/form_validators.dart +++ b/app/lib/utils/form_validators.dart @@ -17,7 +17,10 @@ * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ +import 'dart:io'; + import 'package:nullability/nullability.dart'; +import 'package:path/path.dart' as p; import 'package:weblibre/utils/uri_parser.dart' as uri_parser; String? validateUrl( @@ -55,3 +58,87 @@ String? validateRequired(String? value, {String message = 'Value required'}) { return message; } + +String? validatePath(String? value) { + if (value == null || value.isEmpty) { + return 'Path cannot be empty'; + } + + // Check for invalid characters + // ignore: unnecessary_raw_strings + final invalidChars = RegExp(r'[<>"|?*]'); + if (invalidChars.hasMatch(value)) { + return 'Path contains invalid characters'; + } + + // Validate path structure + try { + p.normalize(value); + } catch (e) { + return 'Invalid path format'; + } + + return null; +} + +String? validateDirectoryExisting(String? value) { + if (value == null || value.isEmpty) { + return 'Path cannot be empty'; + } + + if (!Directory(value).existsSync()) { + return 'Directory is not existing'; + } + + return null; +} + +String? validateDirectoryNotExisting(String? value) { + if (value == null || value.isEmpty) { + return 'Path cannot be empty'; + } + + if (Directory(value).existsSync()) { + return 'Directory already exisits'; + } + + return null; +} + +String? validateFileNotExisting(String? value) { + if (value == null || value.isEmpty) { + return 'Path cannot be empty'; + } + + if (File(value).existsSync()) { + return 'File already exisits'; + } + + return null; +} + +String? validateFileExisting(String? value) { + if (value == null || value.isEmpty) { + return 'Path cannot be empty'; + } + + if (!File(value).existsSync()) { + return 'File not existing'; + } + + return null; +} + +final _profileNamePattern = RegExp(r"""^[^~)('!*<>:;,?"*|/_]+$"""); + +String? validateProfileName(String? value) { + if (value == null || value.isEmpty) { + return 'Name required'; + } + + if (!_profileNamePattern.hasMatch(value)) { + return 'Name contains invalid caharcters'; + } + + return null; +} diff --git a/app/pubspec.yaml b/app/pubspec.yaml index baa7e3c4..d6a84565 100644 --- a/app/pubspec.yaml +++ b/app/pubspec.yaml @@ -11,13 +11,16 @@ dependencies: animated_tree_view: ^2.3.0 background_fetch: ^1.5.0 collection: ^1.19.1 + convert: ^3.1.2 copy_with_extension: ^10.0.1 country_flags: ^4.1.0 + cryptography_flutter: ^2.3.4 drift: ^2.30.0 drift_dev: ^2.30.0 dynamic_color: ^1.8.1 exceptions: ^0.6.1 fading_scroll: ^0.9.1 + fancy_password_field: ^2.0.8 fast_equatable: ^1.3.1 flutter: sdk: flutter @@ -62,6 +65,12 @@ dependencies: riverpod_annotation: ^3.0.3 rss_dart: ^1.0.14 rxdart: ^0.28.0 + saf_stream: ^0.12.3 + saf_util: ^0.11.0 + secure_archive: + git: + url: https://github.com/FaFre/secure_archive.git + path: packages/secure_archive share_plus: ^12.0.1 simple_intent_receiver: path: ../packages/simple_intent_receiver