From 053d4861df1690648544389481b7d3ba6ba893e7 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Mon, 23 Mar 2026 13:57:08 +0100 Subject: [PATCH] exclude cache --- .../presentation/screens/profile_backup.dart | 15 +++ .../user/domain/services/user_backup.dart | 103 +++++++++++++++++- 2 files changed, 117 insertions(+), 1 deletion(-) diff --git a/app/lib/features/user/domain/presentation/screens/profile_backup.dart b/app/lib/features/user/domain/presentation/screens/profile_backup.dart index 1867afb4..f853eac4 100644 --- a/app/lib/features/user/domain/presentation/screens/profile_backup.dart +++ b/app/lib/features/user/domain/presentation/screens/profile_backup.dart @@ -43,6 +43,7 @@ class ProfileBackupScreen extends HookConsumerWidget { final passwordController = useMemoized(() => FancyPasswordController()); final integrityVerification = useState(true); + final skipCaches = useState(false); final skipPasswordConfirmation = useState(false); final backupFuture = useState?>(null); @@ -111,6 +112,19 @@ class ProfileBackupScreen extends HookConsumerWidget { 'Automatically check that backups are complete and restorable', ), ), + SwitchListTile( + contentPadding: EdgeInsets.zero, + value: skipCaches.value, + onChanged: disableInteraction + ? null + : (value) { + skipCaches.value = value; + }, + title: const Text('Skip Cache Directories'), + subtitle: const Text( + 'Leave out temporary browser caches like page, icon, and thumbnail data to keep backups smaller', + ), + ), ExpansionTile( enabled: !disableInteraction, childrenPadding: EdgeInsets.zero, @@ -175,6 +189,7 @@ class ProfileBackupScreen extends HookConsumerWidget { profile, password: passwordTextController.text, integrityCheck: integrityVerification.value, + skipCaches: skipCaches.value, ); } }, diff --git a/app/lib/features/user/domain/services/user_backup.dart b/app/lib/features/user/domain/services/user_backup.dart index e49239c8..94f8a8c8 100644 --- a/app/lib/features/user/domain/services/user_backup.dart +++ b/app/lib/features/user/domain/services/user_backup.dart @@ -38,10 +38,89 @@ part 'user_backup.g.dart'; @Riverpod(keepAlive: true) class UserBackupService extends _$UserBackupService { static final dateFormatter = FixedDateTimeFormatter('YYYY-MM-DD_hhmmss'); + static const _excludedBackupRelativePaths = {'cache'}; static final _safUtil = SafUtil(); static final _safStream = SafStream(); + bool _isExcludedBackupPath(String relativePath) { + final normalizedPath = p.normalize(relativePath); + + for (final excludedPath in _excludedBackupRelativePaths) { + if (normalizedPath == excludedPath || + p.isWithin(excludedPath, normalizedPath)) { + return true; + } + } + + return false; + } + + Future _copyCuratedBackupSource( + Directory rootDirectory, + Directory sourceDirectory, + Directory targetDirectory, + ) async { + await targetDirectory.create(recursive: true); + + await for (final entity in sourceDirectory.list(followLinks: false)) { + final relativePath = p.relative(entity.path, from: rootDirectory.path); + + if (_isExcludedBackupPath(relativePath)) { + continue; + } + + final targetPath = p.join(targetDirectory.path, p.basename(entity.path)); + + if (entity is Directory) { + await _copyCuratedBackupSource( + rootDirectory, + entity, + Directory(targetPath), + ); + } else if (entity is File) { + await entity.copy(targetPath); + } else if (entity is Link) { + await Link(targetPath).create(await entity.target()); + } + } + } + + Future _prepareBackupSourceDirectory( + Directory sourceDirectory, { + required bool skipCaches, + }) async { + if (!skipCaches) { + return sourceDirectory; + } + + final tempDirectory = await getTemporaryDirectory(); + final curatedDirectory = Directory( + p.join( + tempDirectory.path, + 'backup_source_${DateTime.now().microsecondsSinceEpoch}', + ), + ); + + try { + await _copyCuratedBackupSource( + sourceDirectory, + sourceDirectory, + curatedDirectory, + ); + return curatedDirectory; + } catch (_) { + try { + if (await curatedDirectory.exists()) { + await curatedDirectory.delete(recursive: true); + } + } catch (_) { + // Ignore cleanup errors for partially copied backup sources. + } + rethrow; + } + } + Uri _requireBackupDirectoryUri() { final uri = ref.read(backupDirectoryUriProvider); if (uri == null) { @@ -61,18 +140,26 @@ class UserBackupService extends _$UserBackupService { Profile profile, { required String password, required bool integrityCheck, + required bool skipCaches, }) async { final dirUri = _requireBackupDirectoryUri(); final timestamp = dateFormatter.encode(DateTime.now()); final fileName = 'backup_${profile.name}_$timestamp.weblibre'; + final sourceDirectory = filesystem.getProfileDir(profile.uuidValue); final tempDir = await getTemporaryDirectory(); final tempFile = File(p.join(tempDir.path, fileName)); + Directory? curatedSourceDirectory; try { + curatedSourceDirectory = await _prepareBackupSourceDirectory( + sourceDirectory, + skipCaches: skipCaches, + ); + final backup = SecureArchivePack( outputFile: tempFile, - sourceDirectory: filesystem.getProfileDir(profile.uuidValue), + sourceDirectory: curatedSourceDirectory, argon2Params: Argon2Params.memoryConstrained(), ); @@ -98,6 +185,20 @@ class UserBackupService extends _$UserBackupService { stackTrace: s, ); } + if (curatedSourceDirectory != null && + curatedSourceDirectory.path != sourceDirectory.path) { + try { + if (await curatedSourceDirectory.exists()) { + await curatedSourceDirectory.delete(recursive: true); + } + } catch (e, s) { + logger.w( + 'Failed to cleanup curated backup directory: ${curatedSourceDirectory.path}', + error: e, + stackTrace: s, + ); + } + } } }