From 2800192c7646a7974b9402211e0eb90ef988ca8c Mon Sep 17 00:00:00 2001 From: Codex Date: Sat, 31 Oct 2026 23:07:09 +0300 Subject: [PATCH] feat: add private file storage for web and Android --- i18n/en.json | 25 + .../android/app/src/main/AndroidManifest.xml | 16 + .../app/alextran/immich/MainActivity.kt | 163 ++--- .../alextran/immich/files/FileVaultPlugin.kt | 158 +++++ .../immich/files/FileVaultProvider.kt | 5 + .../app/src/main/res/xml/file_vault_paths.xml | 4 + mobile/lib/data/server/file_vault_api.dart | 160 +++++ .../lib/domain/models/file_vault.model.dart | 41 ++ .../repositories/file_vault.repository.dart | 13 + .../upload/share_intent_attachment.model.dart | 2 +- mobile/lib/pages/common/tab_shell.page.dart | 17 +- .../presentation/pages/file_vault.page.dart | 535 +++++++++++++++ .../presentation/utils/file_vault_labels.dart | 78 +++ .../share_intent_upload.provider.dart | 5 + mobile/lib/providers/tab.provider.dart | 2 +- .../share_handler.repository.dart | 4 +- mobile/lib/routing/router.dart | 3 + mobile/pigeon/file_vault_api.dart | 28 + mobile/pubspec.yaml | 2 +- mobile/test/unit/file_vault_api_test.dart | 163 +++++ open-api/immich-openapi-specs.json | 629 ++++++++++++++++++ packages/sdk/src/fetch-client.ts | 159 +++++ .../controllers/file-vault.controller.spec.ts | 69 ++ .../src/controllers/file-vault.controller.ts | 140 ++++ server/src/controllers/index.ts | 2 + server/src/dtos/file-vault.dto.ts | 69 ++ server/src/enum.ts | 1 + server/src/queries/user.repository.sql | 7 + .../src/repositories/file-vault.repository.ts | 401 +++++++++++ server/src/repositories/index.ts | 2 + server/src/repositories/user.repository.ts | 19 +- server/src/schema/index.ts | 5 + .../migrations/1793471628509-AddFileVault.ts | 45 ++ server/src/schema/migrations/ORDER | 1 + server/src/schema/tables/file-vault.table.ts | 64 ++ server/src/services/base.service.ts | 4 + .../src/services/file-vault.service.spec.ts | 44 ++ server/src/services/file-vault.service.ts | 156 +++++ server/src/services/futo-backups.service.ts | 2 +- server/src/services/index.ts | 2 + server/src/utils/access.ts | 3 + server/test/medium.factory.ts | 3 + .../file-vault.repository.spec.ts | 236 +++++++ server/test/utils.ts | 4 + .../side-bar/UserSidebar.svelte | 4 + web/src/lib/queries/file-vault.query.ts | 77 +++ web/src/lib/route.ts | 1 + web/src/lib/utils/file-vault-i18n.ts | 32 + web/src/routes/(user)/files/+page.svelte | 428 ++++++++++++ web/src/routes/(user)/files/+page.ts | 16 + 50 files changed, 3956 insertions(+), 93 deletions(-) create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultPlugin.kt create mode 100644 mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultProvider.kt create mode 100644 mobile/android/app/src/main/res/xml/file_vault_paths.xml create mode 100644 mobile/lib/data/server/file_vault_api.dart create mode 100644 mobile/lib/domain/models/file_vault.model.dart create mode 100644 mobile/lib/infrastructure/repositories/file_vault.repository.dart create mode 100644 mobile/lib/presentation/pages/file_vault.page.dart create mode 100644 mobile/lib/presentation/utils/file_vault_labels.dart create mode 100644 mobile/pigeon/file_vault_api.dart create mode 100644 mobile/test/unit/file_vault_api_test.dart create mode 100644 server/src/controllers/file-vault.controller.spec.ts create mode 100644 server/src/controllers/file-vault.controller.ts create mode 100644 server/src/dtos/file-vault.dto.ts create mode 100644 server/src/repositories/file-vault.repository.ts create mode 100644 server/src/schema/migrations/1793471628509-AddFileVault.ts create mode 100644 server/src/schema/tables/file-vault.table.ts create mode 100644 server/src/services/file-vault.service.spec.ts create mode 100644 server/src/services/file-vault.service.ts create mode 100644 server/test/medium/specs/repositories/file-vault.repository.spec.ts create mode 100644 web/src/lib/queries/file-vault.query.ts create mode 100644 web/src/lib/utils/file-vault-i18n.ts create mode 100644 web/src/routes/(user)/files/+page.svelte create mode 100644 web/src/routes/(user)/files/+page.ts diff --git a/i18n/en.json b/i18n/en.json index 09fb7aaca..8e057cb00 100644 --- a/i18n/en.json +++ b/i18n/en.json @@ -1175,6 +1175,31 @@ "file_name_or_extension": "File name or extension", "file_name_text": "File name", "file_size": "File size", + "file_vault": { + "conflict": "This name already exists. Rename the item or choose another folder.", + "description": "Personal documents and work folders", + "drop": "Drop files or folders here", + "empty": "This folder is empty", + "empty_trash": "Trash is empty", + "error": "The action failed. Check your connection and try again.", + "folder_name": "Folder name", + "invalid": "Check the name and destination folder. Names cannot contain slash characters.", + "limit": "Maximum file size", + "move_to": "Move to folder", + "new_folder": "New folder", + "private": "Only you can access these files.", + "purge_confirm": "Delete permanently? This item and its contents cannot be restored.", + "quota": "Insufficient storage space or the maximum file size was exceeded.", + "root": "My files", + "selected": "Files selected", + "title": "Files", + "trash_confirm": "Move to trash? The folder and its contents will be moved together.", + "unavailable": "This file or folder is unavailable. Refresh the list.", + "upload_files": "Upload files", + "upload_folder": "Upload folder", + "uploading": "Uploading", + "used": "Used" + }, "filename": "Filename", "filetype": "Filetype", "filter": "Filter", diff --git a/mobile/android/app/src/main/AndroidManifest.xml b/mobile/android/app/src/main/AndroidManifest.xml index 938a49e39..ab5d04c72 100644 --- a/mobile/android/app/src/main/AndroidManifest.xml +++ b/mobile/android/app/src/main/AndroidManifest.xml @@ -31,6 +31,11 @@ android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false" android:networkSecurityConfig="@xml/network_security_config"> + + + + + + + + + + + + + + + diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt index 742bf007a..97d9cd2c1 100644 --- a/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/MainActivity.kt @@ -1,81 +1,82 @@ -package app.alextran.immich - -import android.content.Context -import android.content.Intent -import android.os.Build -import android.os.ext.SdkExtensions -import app.alextran.immich.background.BackgroundEngineLock -import app.alextran.immich.background.BackgroundWorkerApiImpl -import app.alextran.immich.background.BackgroundWorkerFgHostApi -import app.alextran.immich.background.BackgroundWorkerLockApi -import app.alextran.immich.connectivity.ConnectivityApi -import app.alextran.immich.connectivity.ConnectivityApiImpl -import app.alextran.immich.core.HttpClientManager -import app.alextran.immich.core.ImmichPlugin -import app.alextran.immich.core.NetworkApiPlugin -import me.albemala.native_video_player.NativeVideoPlayerPlugin -import app.alextran.immich.images.LocalImageApi -import app.alextran.immich.images.LocalImagesImpl -import app.alextran.immich.images.RemoteImageApi -import app.alextran.immich.images.RemoteImagesImpl -import app.alextran.immich.permission.PermissionApi -import app.alextran.immich.permission.PermissionApiImpl -import app.alextran.immich.sync.NativeSyncApi -import app.alextran.immich.sync.NativeSyncApiImpl26 -import app.alextran.immich.sync.NativeSyncApiImpl30 -import app.alextran.immich.viewintent.ViewIntentPlugin -import io.flutter.embedding.android.FlutterFragmentActivity -import io.flutter.embedding.engine.FlutterEngine - -class MainActivity : FlutterFragmentActivity() { - override fun configureFlutterEngine(flutterEngine: FlutterEngine) { - super.configureFlutterEngine(flutterEngine) - AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger) - registerPlugins(this, flutterEngine) - } - - override fun onNewIntent(intent: Intent) { - super.onNewIntent(intent) - setIntent(intent) - } - - companion object { - fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { - HttpClientManager.initialize(ctx) - NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory - flutterEngine.plugins.add(NetworkApiPlugin()) - - val messenger = flutterEngine.dartExecutor.binaryMessenger - val backgroundEngineLockImpl = BackgroundEngineLock(ctx) - BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) - val nativeSyncApiImpl = - if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { - NativeSyncApiImpl26(ctx) - } else { - NativeSyncApiImpl30(ctx) - } - val permissionApiImpl = PermissionApiImpl(ctx) - NativeSyncApi.setUp(messenger, nativeSyncApiImpl) - PermissionApi.setUp(messenger, permissionApiImpl) - LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) - RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx)) - - BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) - ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) - - flutterEngine.plugins.add(ViewIntentPlugin()) - flutterEngine.plugins.add(backgroundEngineLockImpl) - flutterEngine.plugins.add(nativeSyncApiImpl) - flutterEngine.plugins.add(permissionApiImpl) - } - - fun cancelPlugins(flutterEngine: FlutterEngine) { - val nativeApi = - flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin? - ?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin? - nativeApi?.detachFromEngine() - val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin? - permissionApi?.detachFromEngine() - } - } -} +package app.alextran.immich + +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.ext.SdkExtensions +import app.alextran.immich.background.BackgroundEngineLock +import app.alextran.immich.background.BackgroundWorkerApiImpl +import app.alextran.immich.background.BackgroundWorkerFgHostApi +import app.alextran.immich.background.BackgroundWorkerLockApi +import app.alextran.immich.connectivity.ConnectivityApi +import app.alextran.immich.connectivity.ConnectivityApiImpl +import app.alextran.immich.core.HttpClientManager +import app.alextran.immich.core.ImmichPlugin +import app.alextran.immich.core.NetworkApiPlugin +import me.albemala.native_video_player.NativeVideoPlayerPlugin +import app.alextran.immich.images.LocalImageApi +import app.alextran.immich.images.LocalImagesImpl +import app.alextran.immich.images.RemoteImageApi +import app.alextran.immich.images.RemoteImagesImpl +import app.alextran.immich.permission.PermissionApi +import app.alextran.immich.permission.PermissionApiImpl +import app.alextran.immich.sync.NativeSyncApi +import app.alextran.immich.sync.NativeSyncApiImpl26 +import app.alextran.immich.sync.NativeSyncApiImpl30 +import app.alextran.immich.viewintent.ViewIntentPlugin +import io.flutter.embedding.android.FlutterFragmentActivity +import io.flutter.embedding.engine.FlutterEngine + +class MainActivity : FlutterFragmentActivity() { + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger) + flutterEngine.plugins.add(app.alextran.immich.files.FileVaultPlugin()) + registerPlugins(this, flutterEngine) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + setIntent(intent) + } + + companion object { + fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { + HttpClientManager.initialize(ctx) + NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory + flutterEngine.plugins.add(NetworkApiPlugin()) + + val messenger = flutterEngine.dartExecutor.binaryMessenger + val backgroundEngineLockImpl = BackgroundEngineLock(ctx) + BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) + val nativeSyncApiImpl = + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { + NativeSyncApiImpl26(ctx) + } else { + NativeSyncApiImpl30(ctx) + } + val permissionApiImpl = PermissionApiImpl(ctx) + NativeSyncApi.setUp(messenger, nativeSyncApiImpl) + PermissionApi.setUp(messenger, permissionApiImpl) + LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) + RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx)) + + BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx)) + ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) + + flutterEngine.plugins.add(ViewIntentPlugin()) + flutterEngine.plugins.add(backgroundEngineLockImpl) + flutterEngine.plugins.add(nativeSyncApiImpl) + flutterEngine.plugins.add(permissionApiImpl) + } + + fun cancelPlugins(flutterEngine: FlutterEngine) { + val nativeApi = + flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin? + ?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin? + nativeApi?.detachFromEngine() + val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin? + permissionApi?.detachFromEngine() + } + } +} diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultPlugin.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultPlugin.kt new file mode 100644 index 000000000..942c8dd9b --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultPlugin.kt @@ -0,0 +1,158 @@ +package app.alextran.immich.files + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.provider.OpenableColumns +import android.webkit.MimeTypeMap +import androidx.core.content.FileProvider +import io.flutter.embedding.engine.plugins.FlutterPlugin +import io.flutter.embedding.engine.plugins.activity.ActivityAware +import io.flutter.embedding.engine.plugins.activity.ActivityPluginBinding +import io.flutter.plugin.common.PluginRegistry.ActivityResultListener +import java.io.File +import java.util.UUID +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +class FileVaultPlugin : FlutterPlugin, ActivityAware, ActivityResultListener, FileVaultPlatform { + private lateinit var context: Context + private lateinit var scope: CoroutineScope + private var activityBinding: ActivityPluginBinding? = null + private var pendingPick: ((Result>) -> Unit)? = null + private var pendingSave: ((Result) -> Unit)? = null + private var sourceToSave: File? = null + + override fun onAttachedToEngine(binding: FlutterPlugin.FlutterPluginBinding) { + context = binding.applicationContext + scope = CoroutineScope(SupervisorJob() + Dispatchers.Main.immediate) + FileVaultPlatform.setUp(binding.binaryMessenger, this) + } + + override fun onDetachedFromEngine(binding: FlutterPlugin.FlutterPluginBinding) { + finishPending() + scope.cancel() + FileVaultPlatform.setUp(binding.binaryMessenger, null) + } + + override fun onAttachedToActivity(binding: ActivityPluginBinding) { + activityBinding = binding + binding.addActivityResultListener(this) + } + + override fun onReattachedToActivityForConfigChanges(binding: ActivityPluginBinding) = onAttachedToActivity(binding) + + override fun onDetachedFromActivityForConfigChanges() { + activityBinding?.removeActivityResultListener(this) + activityBinding = null + } + + override fun onDetachedFromActivity() { + onDetachedFromActivityForConfigChanges() + finishPending() + } + + private fun finishPending() { + pendingPick?.invoke(Result.success(emptyList())) + pendingSave?.invoke(Result.success(false)) + pendingPick = null + pendingSave = null + sourceToSave = null + } + + override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?): Boolean { + when (requestCode) { + PICK_FILES -> { + val callback = pendingPick ?: return true + pendingPick = null + if (resultCode != Activity.RESULT_OK) { callback(Result.success(emptyList())); return true } + val uris = data?.clipData?.let { clip -> (0 until clip.itemCount).map { clip.getItemAt(it).uri } } + ?: listOfNotNull(data?.data) + scope.launch { + val result = withContext(Dispatchers.IO) { + val created = mutableListOf() + runCatching { + uris.map { uri -> + val name = context.contentResolver.query(uri, arrayOf(OpenableColumns.DISPLAY_NAME), null, null, null)?.use { cursor -> + if (cursor.moveToFirst()) cursor.getString(0) else null + } ?: "file" + val directory = File(context.cacheDir, "file-vault-upload/${UUID.randomUUID()}").apply { mkdirs() } + created.add(directory) + val target = File(directory, File(name).name) + context.contentResolver.openInputStream(uri)?.use { input -> target.outputStream().use { output -> input.copyTo(output) } } + ?: throw IllegalStateException("Cannot read the selected file") + VaultPickedFile(target.absolutePath, name) + } + }.onFailure { created.forEach { it.deleteRecursively() } } + } + callback(result) + } + return true + } + SAVE_FILE -> { + val callback = pendingSave ?: return true + val source = sourceToSave + pendingSave = null + sourceToSave = null + val uri = data?.data + if (resultCode != Activity.RESULT_OK || uri == null || source == null) { callback(Result.success(false)); return true } + scope.launch { + val result = withContext(Dispatchers.IO) { + runCatching { + context.contentResolver.openOutputStream(uri, "wt")?.use { output -> source.inputStream().use { input -> input.copyTo(output) } } + ?: throw IllegalStateException("Cannot write the selected file") + true + } + } + callback(result) + } + return true + } + } + return false + } + + override fun pickFiles(callback: (Result>) -> Unit) { + if (pendingPick != null) { callback(Result.failure(IllegalStateException("File picker is already open"))); return } + try { + val activity = activityBinding?.activity ?: throw IllegalStateException("No active activity") + pendingPick = callback + activity.startActivityForResult(Intent(Intent.ACTION_OPEN_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE); type = "*/*"; putExtra(Intent.EXTRA_ALLOW_MULTIPLE, true) }, PICK_FILES) + } catch (error: Exception) { pendingPick = null; callback(Result.failure(error)) } + } + + private fun downloadedFile(path: String): File { + val root = File(context.cacheDir, "file-vault-downloads").canonicalFile + val file = File(path).canonicalFile + require(file.path.startsWith(root.path + File.separator) && file.isFile) { "Invalid download path" } + return file + } + + override fun saveFile(sourcePath: String, suggestedName: String, callback: (Result) -> Unit) { + if (pendingSave != null) { callback(Result.failure(IllegalStateException("Save dialog is already open"))); return } + try { + val activity = activityBinding?.activity ?: throw IllegalStateException("No active activity") + sourceToSave = downloadedFile(sourcePath) + pendingSave = callback + activity.startActivityForResult(Intent(Intent.ACTION_CREATE_DOCUMENT).apply { addCategory(Intent.CATEGORY_OPENABLE); type = "application/octet-stream"; putExtra(Intent.EXTRA_TITLE, suggestedName) }, SAVE_FILE) + } catch (error: Exception) { pendingSave = null; sourceToSave = null; callback(Result.failure(error)) } + } + + override fun openFile(sourcePath: String, suggestedName: String) { + val activity = activityBinding?.activity ?: throw IllegalStateException("No active activity") + val file = downloadedFile(sourcePath) + val uri = FileProvider.getUriForFile(context, context.packageName + ".files", file) + val mime = MimeTypeMap.getSingleton().getMimeTypeFromExtension(suggestedName.substringAfterLast('.', "").lowercase()) ?: "application/octet-stream" + val intent = Intent(Intent.ACTION_VIEW).apply { setDataAndType(uri, mime); addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION) } + activity.startActivity(Intent.createChooser(intent, suggestedName)) + } + + companion object { + private const val PICK_FILES = 62465 + private const val SAVE_FILE = 62466 + } +} \ No newline at end of file diff --git a/mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultProvider.kt b/mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultProvider.kt new file mode 100644 index 000000000..174cd6314 --- /dev/null +++ b/mobile/android/app/src/main/kotlin/app/alextran/immich/files/FileVaultProvider.kt @@ -0,0 +1,5 @@ +package app.alextran.immich.files + +import androidx.core.content.FileProvider + +class FileVaultProvider : FileProvider() diff --git a/mobile/android/app/src/main/res/xml/file_vault_paths.xml b/mobile/android/app/src/main/res/xml/file_vault_paths.xml new file mode 100644 index 000000000..5e570c834 --- /dev/null +++ b/mobile/android/app/src/main/res/xml/file_vault_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/mobile/lib/data/server/file_vault_api.dart b/mobile/lib/data/server/file_vault_api.dart new file mode 100644 index 000000000..e041e75dc --- /dev/null +++ b/mobile/lib/data/server/file_vault_api.dart @@ -0,0 +1,160 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:http/http.dart' as http; +import 'package:immich_mobile/domain/models/file_vault.model.dart'; +import 'package:immich_mobile/platform/file_vault_api.g.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; +import 'package:path/path.dart' as p; +import 'package:path_provider/path_provider.dart'; + +class FileVaultApiData { + FileVaultApiData(this._apiService, this.platform, {Future Function()? temporaryDirectory, this.onChanged}) + : _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory; + + final ApiService _apiService; + final FileVaultPlatform platform; + final Future Function() _temporaryDirectory; + final void Function()? onChanged; + FileVaultApi get _api => FileVaultApi(_apiService.apiClient); + + VaultEntry _entry(FileVaultEntryDto dto) => VaultEntry( + id: dto.id, + name: dto.name, + parentId: dto.parentId, + isFolder: dto.isFolder, + size: dto.size, + checksum: dto.checksum, + ); + VaultFolder _folder(FileVaultFolderDto dto) => VaultFolder(id: dto.id, name: dto.name, parentId: dto.parentId); + + Future list({String? parentId, bool trash = false, int offset = 0}) async { + final dto = await _api.listFiles(parentId: parentId, trash: trash ? 'true' : 'false', offset: offset); + if (dto == null) { + throw const FormatException('Empty file listing'); + } + return VaultListing( + entries: dto.entries.map(_entry).toList(), + breadcrumbs: dto.breadcrumbs.map(_folder).toList(), + hasMore: dto.hasMore, + usedBytes: dto.usedBytes, + maxFileBytes: dto.maxFileBytes, + ); + } + + Future> folders() async => (await _api.listFileFolders() ?? []).map(_folder).toList(); + Future createFolder(String name, String? parentId) async { + await _api.createFileFolder(FileVaultCreateFolderDto(name: name, parentId: Optional.present(parentId))); + onChanged?.call(); + } + + Future rename(String id, String name) async { + await _api.updateFile(id, FileVaultUpdateDto(name: Optional.present(name))); + onChanged?.call(); + } + + Future move(String id, String? parentId) async { + await _api.updateFile(id, FileVaultUpdateDto(parentId: Optional.present(parentId))); + onChanged?.call(); + } + + Future trash(String id) async { + await _api.trashFile(id); + onChanged?.call(); + } + + Future restore(String id) async { + await _api.restoreFile(id); + onChanged?.call(); + } + + Future purge(String id) async { + await _api.permanentlyDeleteFile(id); + onChanged?.call(); + } + + Future upload(String path, String name, String? parentId, void Function(int, int) onProgress) async { + final file = File(path); + final length = await file.length(); + var sent = 0; + final body = http.MultipartFile( + 'file', + file.openRead().map((chunk) { + sent += chunk.length; + onProgress(sent, length); + return chunk; + }), + length, + filename: name, + ); + final response = await _apiService.apiClient.invokeAPI( + '/file-vault/upload', + 'POST', + [QueryParam('name', name), if (parentId != null) QueryParam('parentId', parentId)], + body, + {}, + {}, + 'application/octet-stream', + ); + if (response.statusCode < 200 || response.statusCode >= 300) { + throw ApiException(response.statusCode, utf8.decode(response.bodyBytes)); + } + final dto = FileVaultEntryDto.fromJson(jsonDecode(utf8.decode(response.bodyBytes))); + if (dto == null) { + throw const FormatException('Missing upload response'); + } + onChanged?.call(); + final hash = await sha256.bind(file.openRead()).first; + if (dto.checksum != hash.toString()) { + throw const FormatException('File integrity verification failed'); + } + } + + Future download(VaultEntry entry, void Function(int, int) onProgress) async { + if (!RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$').hasMatch(entry.id) || + entry.size < 0) { + throw const FormatException('Invalid file metadata'); + } + final temporary = await _temporaryDirectory(); + final directory = Directory(p.join(temporary.path, 'file-vault-downloads', entry.id)); + await directory.create(recursive: true); + final target = File(p.join(directory.path, p.basename(entry.name) + (entry.isFolder ? '.zip' : ''))); + final partial = File('${target.path}.part'); + final request = http.Request('GET', Uri.parse('${_apiService.apiClient.basePath}/file-vault/${entry.id}/download')); + request.headers.addAll(_apiService.apiClient.defaultHeaderMap); + final response = await _apiService.apiClient.client.send(request); + if (response.statusCode != 200) { + await response.stream.drain(); + throw ApiException(response.statusCode, 'Download failed'); + } + var received = 0; + final output = partial.openWrite(); + try { + await output.addStream( + response.stream.map((chunk) { + received += chunk.length; + if (!entry.isFolder && received > entry.size) { + throw const FormatException('Download exceeds the expected size'); + } + onProgress(received, entry.size); + return chunk; + }), + ); + await output.flush(); + await output.close(); + final hash = await sha256.bind(partial.openRead()).first; + if (entry.isFolder ? received == 0 : received != entry.size || hash.toString() != entry.checksum) { + throw const FormatException('File integrity verification failed'); + } + return await partial.rename(target.path); + } catch (_) { + await output.close(); + if (partial.existsSync()) { + await partial.delete(); + } + rethrow; + } + } +} diff --git a/mobile/lib/domain/models/file_vault.model.dart b/mobile/lib/domain/models/file_vault.model.dart new file mode 100644 index 000000000..a15d27667 --- /dev/null +++ b/mobile/lib/domain/models/file_vault.model.dart @@ -0,0 +1,41 @@ +class VaultEntry { + const VaultEntry({ + required this.id, + required this.name, + required this.parentId, + required this.isFolder, + required this.size, + required this.checksum, + }); + + final String id; + final String name; + final String? parentId; + final bool isFolder; + final int size; + final String? checksum; +} + +class VaultFolder { + const VaultFolder({required this.id, required this.name, required this.parentId}); + + final String id; + final String name; + final String? parentId; +} + +class VaultListing { + const VaultListing({ + required this.entries, + required this.breadcrumbs, + required this.hasMore, + required this.usedBytes, + required this.maxFileBytes, + }); + + final List entries; + final List breadcrumbs; + final bool hasMore; + final int usedBytes; + final int maxFileBytes; +} diff --git a/mobile/lib/infrastructure/repositories/file_vault.repository.dart b/mobile/lib/infrastructure/repositories/file_vault.repository.dart new file mode 100644 index 000000000..fd411708d --- /dev/null +++ b/mobile/lib/infrastructure/repositories/file_vault.repository.dart @@ -0,0 +1,13 @@ +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/data/server/file_vault_api.dart'; +import 'package:immich_mobile/platform/file_vault_api.g.dart'; +import 'package:immich_mobile/providers/api.provider.dart'; + +final fileVaultRevisionProvider = StateProvider((_) => 0); +final fileVaultRepositoryProvider = Provider( + (ref) => FileVaultApiData( + ref.watch(apiServiceProvider), + FileVaultPlatform(), + onChanged: () => ref.read(fileVaultRevisionProvider.notifier).state++, + ), +); diff --git a/mobile/lib/models/upload/share_intent_attachment.model.dart b/mobile/lib/models/upload/share_intent_attachment.model.dart index bcd9923bd..6040f76a5 100644 --- a/mobile/lib/models/upload/share_intent_attachment.model.dart +++ b/mobile/lib/models/upload/share_intent_attachment.model.dart @@ -6,7 +6,7 @@ import 'package:path/path.dart'; part 'share_intent_attachment.model.freezed.dart'; -enum ShareIntentAttachmentType { image, video } +enum ShareIntentAttachmentType { image, video, file } enum UploadStatus { enqueued, running, complete, failed } diff --git a/mobile/lib/pages/common/tab_shell.page.dart b/mobile/lib/pages/common/tab_shell.page.dart index 409c6f091..76fbc1e1b 100644 --- a/mobile/lib/pages/common/tab_shell.page.dart +++ b/mobile/lib/pages/common/tab_shell.page.dart @@ -1,4 +1,5 @@ import 'dart:async'; +import 'dart:io'; import 'package:auto_route/auto_route.dart'; import 'package:flutter/material.dart'; @@ -9,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart'; import 'package:immich_mobile/extensions/build_context_extensions.dart'; import 'package:immich_mobile/generated/translations.g.dart'; import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart'; +import 'package:immich_mobile/presentation/utils/file_vault_labels.dart'; import 'package:immich_mobile/providers/haptic_feedback.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; @@ -55,6 +57,13 @@ class _TabShellPageState extends ConsumerState { selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor), enabled: !isReadonlyModeEnabled, ), + if (Platform.isAndroid) + NavigationDestination( + label: FileVaultLabels(Localizations.localeOf(context).languageCode).text('title'), + icon: const Icon(Icons.folder_outlined), + selectedIcon: Icon(Icons.folder, color: context.primaryColor), + enabled: !isReadonlyModeEnabled, + ), ]; Widget navigationRail(TabsRouter tabsRouter) { @@ -77,7 +86,13 @@ class _TabShellPageState extends ConsumerState { } return AutoTabsRouter( - routes: const [MainTimelineRoute(), SearchRoute(), AlbumsRoute(), LibraryRoute()], + routes: [ + const MainTimelineRoute(), + const SearchRoute(), + const AlbumsRoute(), + const LibraryRoute(), + if (Platform.isAndroid) FileVaultRoute(), + ], duration: const Duration(milliseconds: 600), transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child), builder: (context, child) { diff --git a/mobile/lib/presentation/pages/file_vault.page.dart b/mobile/lib/presentation/pages/file_vault.page.dart new file mode 100644 index 000000000..094f297ef --- /dev/null +++ b/mobile/lib/presentation/pages/file_vault.page.dart @@ -0,0 +1,535 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:auto_route/auto_route.dart'; +import 'package:flutter/material.dart'; +import 'package:hooks_riverpod/hooks_riverpod.dart'; +import 'package:immich_mobile/domain/models/file_vault.model.dart'; +import 'package:immich_mobile/infrastructure/repositories/file_vault.repository.dart'; +import 'package:immich_mobile/presentation/utils/file_vault_labels.dart'; +import 'package:openapi/api.dart'; +import 'package:path/path.dart' as p; + +@RoutePage() +class FileVaultPage extends ConsumerStatefulWidget { + const FileVaultPage({super.key, this.incomingPaths = const []}); + + final List incomingPaths; + + @override + ConsumerState createState() => _FileVaultPageState(); +} + +class _FileVaultPageState extends ConsumerState { + VaultListing? _listing; + String? _folderId; + bool _trash = false; + bool _busy = false; + int _offset = 0; + int _generation = 0; + String? _error; + String? _progress; + late List _incoming; + FileVaultLabels get _labels => FileVaultLabels(Localizations.localeOf(context).languageCode); + + @override + void initState() { + super.initState(); + _incoming = [...widget.incomingPaths]; + ref.listenManual(fileVaultRevisionProvider, (_, _) => unawaited(_load())); + unawaited(Future.microtask(_load)); + } + + Future _load() async { + final generation = ++_generation; + try { + final listing = await ref + .read(fileVaultRepositoryProvider) + .list(parentId: _folderId, trash: _trash, offset: _offset); + if (mounted && generation == _generation) { + setState(() { + _listing = listing; + _error = null; + }); + } + } catch (error) { + if (mounted && generation == _generation) { + _showError(error); + } + } + } + + void _showError(Object error) { + final status = error is ApiException ? error.code : 0; + setState( + () => _error = _labels.text( + status == 409 + ? 'conflict' + : status == 404 + ? 'unavailable' + : status == 413 || status == 507 + ? 'quota' + : status == 400 + ? 'invalid' + : 'error', + ), + ); + } + + Future _run(Future Function() action) async { + if (_busy) { + return; + } + setState(() { + _busy = true; + _error = null; + }); + try { + await action(); + if (mounted) { + await _load(); + } + } catch (error) { + if (mounted) { + _showError(error); + } + } finally { + if (mounted) { + setState(() { + _busy = false; + _progress = null; + }); + } + } + } + + void _navigate(String? folderId, {bool trash = false, int offset = 0}) { + if (_busy) { + return; + } + setState(() { + _folderId = folderId; + _trash = trash; + _offset = offset; + _listing = null; + }); + unawaited(_load()); + } + + String _size(int bytes) { + if (bytes < 1024) { + return '$bytes ${_labels.text('byte_unit')}'; + } + final unit = bytes >= 1024 * 1024 * 1024 + ? 'GiB' + : bytes >= 1024 * 1024 + ? 'MiB' + : 'KiB'; + final divisor = unit == 'GiB' + ? 1024 * 1024 * 1024 + : unit == 'MiB' + ? 1024 * 1024 + : 1024; + return '${(bytes / divisor).toStringAsFixed(1)} $unit'; + } + + Future _askName(String title, {String initial = ''}) async { + final controller = TextEditingController(text: initial); + try { + return await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(title), + content: TextField( + controller: controller, + autofocus: true, + maxLength: 240, + decoration: InputDecoration(labelText: _labels.text('name')), + onSubmitted: (value) { + if (value.trim().isNotEmpty) { + Navigator.pop(dialogContext, value.trim()); + } + }, + ), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(_labels.text('cancel'))), + FilledButton( + onPressed: () { + if (controller.text.trim().isNotEmpty) { + Navigator.pop(dialogContext, controller.text.trim()); + } + }, + child: Text(_labels.text('confirm')), + ), + ], + ), + ); + } finally { + controller.dispose(); + } + } + + Future _confirm(VaultEntry entry, bool permanent) async => + await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(_labels.text(permanent ? 'purge' : 'trash')), + content: Text('${entry.name}\n\n${_labels.text(permanent ? 'purge_confirm' : 'trash_confirm')}'), + actions: [ + TextButton(onPressed: () => Navigator.pop(dialogContext, false), child: Text(_labels.text('cancel'))), + FilledButton(onPressed: () => Navigator.pop(dialogContext, true), child: Text(_labels.text('confirm'))), + ], + ), + ) ?? + false; + + Future _createFolder() async { + final name = await _askName(_labels.text('new_folder')); + if (name != null && mounted) { + await _run(() => ref.read(fileVaultRepositoryProvider).createFolder(name, _folderId)); + } + } + + Future _pickFiles() async { + await _run(() async { + final repository = ref.read(fileVaultRepositoryProvider); + final files = await repository.platform.pickFiles(); + try { + for (var index = 0; index < files.length; index++) { + final file = files[index]; + if (!mounted) { + break; + } + await repository.upload(file.path, file.name, _folderId, (bytes, total) { + if (mounted) { + setState( + () => _progress = + '${index + 1}/${files.length} · ${file.name} · ${total > 0 ? bytes * 100 ~/ total : 0}%', + ); + } + }); + } + } finally { + for (final file in files) { + final temporary = File(file.path); + if (temporary.existsSync()) { + await temporary.delete(); + } + } + } + }); + } + + Future _uploadIncoming() async { + await _run(() async { + final repository = ref.read(fileVaultRepositoryProvider); + while (_incoming.isNotEmpty) { + final path = _incoming.first; + final name = p.basename(path); + await repository.upload(path, name, _folderId, (bytes, total) { + if (mounted) { + setState(() => _progress = '$name · ${total > 0 ? bytes * 100 ~/ total : 0}%'); + } + }); + if (!mounted) { + return; + } + setState(() => _incoming.removeAt(0)); + } + }); + } + + Future _move(VaultEntry entry) async { + await _run(() async { + final repository = ref.read(fileVaultRepositoryProvider); + final folders = await repository.folders(); + if (!mounted) { + return; + } + bool allowed(VaultFolder folder) { + final seen = {}; + VaultFolder? current = folder; + while (current != null && seen.add(current.id)) { + if (current.id == entry.id) { + return false; + } + final parentId = current.parentId; + current = folders.where((item) => item.id == parentId).firstOrNull; + } + return true; + } + + String path(VaultFolder folder) { + final parts = [folder.name]; + final seen = {folder.id}; + var parentId = folder.parentId; + while (parentId != null && seen.add(parentId)) { + final parent = folders.where((item) => item.id == parentId).firstOrNull; + if (parent == null) { + break; + } + parts.insert(0, parent.name); + parentId = parent.parentId; + } + return parts.join(' / '); + } + + final destination = await showDialog( + context: context, + builder: (dialogContext) => AlertDialog( + title: Text(_labels.text('move_to')), + content: SizedBox( + width: double.maxFinite, + child: ListView( + shrinkWrap: true, + children: [ + ListTile( + leading: const Icon(Icons.home_outlined), + title: Text(_labels.text('root')), + onTap: () => Navigator.pop(dialogContext, 'root'), + ), + for (final folder in folders.where(allowed)) + ListTile( + leading: const Icon(Icons.folder_outlined), + title: Text(path(folder)), + onTap: () => Navigator.pop(dialogContext, folder.id), + ), + ], + ), + ), + actions: [TextButton(onPressed: () => Navigator.pop(dialogContext), child: Text(_labels.text('cancel')))], + ), + ); + if (destination != null) { + await repository.move(entry.id, destination == 'root' ? null : destination); + } + }); + } + + Future _download(VaultEntry entry, {bool open = false}) async { + await _run(() async { + final repository = ref.read(fileVaultRepositoryProvider); + final file = await repository.download(entry, (bytes, total) { + if (mounted) { + setState(() => _progress = '${entry.name} · ${total > 0 ? bytes * 100 ~/ total : 0}%'); + } + }); + if (!mounted) { + if (file.existsSync()) { + await file.delete(); + } + return; + } + if (open) { + await repository.platform.openFile(file.path, p.basename(file.path)); + } else { + try { + await repository.platform.saveFile(file.path, p.basename(file.path)); + } finally { + if (file.existsSync()) { + await file.delete(); + } + } + } + }); + } + + Future _action(String action, VaultEntry entry) async { + if (action == 'move') { + await _move(entry); + return; + } + if (action == 'download' || action == 'open') { + await _download(entry, open: action == 'open'); + return; + } + if (action == 'rename') { + final name = await _askName(_labels.text('rename'), initial: entry.name); + if (name != null && mounted) { + await _run(() => ref.read(fileVaultRepositoryProvider).rename(entry.id, name)); + } + return; + } + if (action == 'trash' || action == 'purge') { + if (!await _confirm(entry, action == 'purge') || !mounted) { + return; + } + } + await _run(() async { + final repository = ref.read(fileVaultRepositoryProvider); + if (action == 'trash') { + await repository.trash(entry.id); + } + if (action == 'purge') { + await repository.purge(entry.id); + } + if (action == 'restore') { + await repository.restore(entry.id); + } + }); + } + + @override + Widget build(BuildContext context) { + final listing = _listing; + final labels = _labels; + return Scaffold( + appBar: AppBar( + title: Text(labels.text(_trash ? 'trash' : 'title')), + actions: [ + IconButton(tooltip: labels.text('refresh'), onPressed: _busy ? null : _load, icon: const Icon(Icons.refresh)), + if (!_trash) + IconButton( + tooltip: labels.text('new_folder'), + onPressed: _busy ? null : _createFolder, + icon: const Icon(Icons.create_new_folder_outlined), + ), + IconButton( + tooltip: labels.text(_trash ? 'root' : 'trash'), + onPressed: _busy ? null : () => _navigate(null, trash: !_trash), + icon: Icon(_trash ? Icons.folder_outlined : Icons.delete_outline), + ), + ], + ), + floatingActionButton: _trash + ? null + : FloatingActionButton.extended( + onPressed: _busy ? null : _pickFiles, + icon: const Icon(Icons.upload_file), + label: Text(labels.text('upload_files')), + ), + body: Column( + children: [ + if (!_trash) + SingleChildScrollView( + scrollDirection: Axis.horizontal, + padding: const EdgeInsets.symmetric(horizontal: 12), + child: Row( + children: [ + TextButton(onPressed: _busy ? null : () => _navigate(null), child: Text(labels.text('root'))), + for (final folder in listing?.breadcrumbs ?? []) + Row( + children: [ + const Icon(Icons.chevron_right, size: 16), + TextButton(onPressed: _busy ? null : () => _navigate(folder.id), child: Text(folder.name)), + ], + ), + ], + ), + ), + if (listing != null) + Padding( + padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), + child: Align( + alignment: Alignment.centerLeft, + child: Text( + '${labels.text('used')}: ${_size(listing.usedBytes)} · ${labels.text('limit')}: ${_size(listing.maxFileBytes)}', + style: Theme.of(context).textTheme.bodySmall, + ), + ), + ), + if (_error != null) + MaterialBanner( + content: Text(_error!), + actions: [TextButton(onPressed: () => setState(() => _error = null), child: Text(labels.text('close')))], + ), + if (_busy) const LinearProgressIndicator(), + if (_progress != null) + Padding( + padding: const EdgeInsets.all(12), + child: Text(_progress!, maxLines: 2, overflow: TextOverflow.ellipsis), + ), + if (_incoming.isNotEmpty && !_trash) + Padding( + padding: const EdgeInsets.all(12), + child: Card( + child: Padding( + padding: const EdgeInsets.all(12), + child: Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + Text('${labels.text('incoming')}: ${_incoming.length}'), + Text(_incoming.map(p.basename).join(', '), maxLines: 3, overflow: TextOverflow.ellipsis), + const SizedBox(height: 8), + FilledButton(onPressed: _busy ? null : _uploadIncoming, child: Text(labels.text('upload_here'))), + TextButton( + onPressed: _busy ? null : () => setState(() => _incoming.clear()), + child: Text(labels.text('cancel')), + ), + ], + ), + ), + ), + ), + Expanded( + child: listing == null + ? Center( + child: _error != null + ? FilledButton(onPressed: _load, child: Text(labels.text('refresh'))) + : const CircularProgressIndicator(), + ) + : RefreshIndicator( + onRefresh: _load, + child: ListView( + children: [ + if (listing.entries.isEmpty) + Padding( + padding: const EdgeInsets.all(40), + child: Center(child: Text(labels.text(_trash ? 'empty_trash' : 'empty'))), + ), + for (final entry in listing.entries) + ListTile( + leading: Icon(entry.isFolder ? Icons.folder_outlined : Icons.description_outlined), + title: Text(entry.name), + subtitle: entry.isFolder ? null : Text(_size(entry.size)), + onTap: _busy + ? null + : entry.isFolder && !_trash + ? () => _navigate(entry.id) + : entry.isFolder || _trash + ? null + : () => _download(entry, open: true), + trailing: PopupMenuButton( + enabled: !_busy, + onSelected: (action) => _action(action, entry), + itemBuilder: (_) => [ + if (!_trash && !entry.isFolder) + PopupMenuItem(value: 'open', child: Text(labels.text('open'))), + if (!_trash) PopupMenuItem(value: 'download', child: Text(labels.text('download'))), + PopupMenuItem(value: 'rename', child: Text(labels.text('rename'))), + if (!_trash) PopupMenuItem(value: 'move', child: Text(labels.text('move'))), + if (!_trash) PopupMenuItem(value: 'trash', child: Text(labels.text('trash'))), + if (_trash) PopupMenuItem(value: 'restore', child: Text(labels.text('restore'))), + if (_trash) PopupMenuItem(value: 'purge', child: Text(labels.text('purge'))), + ], + ), + ), + Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (_offset > 0) + TextButton( + onPressed: _busy + ? null + : () => _navigate(_folderId, trash: _trash, offset: _offset - 100), + child: Text(labels.text('previous')), + ), + if (listing.hasMore) + TextButton( + onPressed: _busy + ? null + : () => _navigate(_folderId, trash: _trash, offset: _offset + 100), + child: Text(labels.text('next')), + ), + ], + ), + const SizedBox(height: 96), + ], + ), + ), + ), + ], + ), + ); + } +} diff --git a/mobile/lib/presentation/utils/file_vault_labels.dart b/mobile/lib/presentation/utils/file_vault_labels.dart new file mode 100644 index 000000000..c4f0183ea --- /dev/null +++ b/mobile/lib/presentation/utils/file_vault_labels.dart @@ -0,0 +1,78 @@ +class FileVaultLabels { + const FileVaultLabels(this.language); + final String language; + + String text(String key) => (language == 'ru' ? _russian[key] : _english[key]) ?? _english[key] ?? key; + + static const _russian = { + 'byte_unit': 'Б', + 'title': 'Файлы', + 'root': 'Мои файлы', + 'new_folder': 'Новая папка', + 'name': 'Название', + 'upload_files': 'Загрузить файлы', + 'empty': 'В этой папке пока нет файлов', + 'empty_trash': 'Корзина пуста', + 'move_to': 'Переместить в папку', + 'used': 'Занято', + 'error': 'Не удалось выполнить действие. Проверьте соединение и повторите попытку.', + 'conflict': 'Такое имя уже существует. Переименуйте файл или выберите другую папку.', + 'unavailable': 'Файл или папка недоступны. Обновите список.', + 'quota': 'Недостаточно места или превышен допустимый размер файла.', + 'invalid': 'Проверьте название и выбранную папку. Название не должно содержать / или \\.', + 'purge_confirm': 'Удалить навсегда? Восстановить этот файл или папку с содержимым будет невозможно.', + 'trash_confirm': 'Папка будет перемещена в корзину вместе с содержимым.', + 'limit': 'Максимум на файл', + 'cancel': 'Отмена', + 'confirm': 'Подтвердить', + 'rename': 'Переименовать', + 'trash': 'Корзина', + 'purge': 'Удалить навсегда', + 'restore': 'Восстановить', + 'move': 'Переместить', + 'open': 'Открыть', + 'download': 'Скачать', + 'refresh': 'Обновить', + 'close': 'Закрыть', + 'incoming': 'Получено файлов', + 'upload_here': 'Загрузить в эту папку', + 'previous': 'Назад', + 'next': 'Далее', + }; + + static const _english = { + 'byte_unit': 'B', + 'title': 'Files', + 'root': 'My files', + 'new_folder': 'New folder', + 'name': 'Name', + 'upload_files': 'Upload files', + 'empty': 'This folder is empty', + 'empty_trash': 'Trash is empty', + 'move_to': 'Move to folder', + 'used': 'Used', + 'error': 'The action failed. Check your connection and try again.', + 'conflict': 'This name already exists. Rename the item or choose another folder.', + 'unavailable': 'This file or folder is unavailable. Refresh the list.', + 'quota': 'Insufficient space or maximum file size exceeded.', + 'invalid': 'Check the name and destination folder. Names cannot contain / or \\.', + 'purge_confirm': 'Delete permanently? This item and its contents cannot be restored.', + 'trash_confirm': 'The folder and its contents will be moved to trash.', + 'limit': 'Maximum file size', + 'cancel': 'Cancel', + 'confirm': 'Confirm', + 'rename': 'Rename', + 'trash': 'Trash', + 'purge': 'Delete permanently', + 'restore': 'Restore', + 'move': 'Move', + 'open': 'Open', + 'download': 'Download', + 'refresh': 'Refresh', + 'close': 'Close', + 'incoming': 'Files received', + 'upload_here': 'Upload to this folder', + 'previous': 'Previous', + 'next': 'Next', + }; +} diff --git a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart index 48039f501..ab2618a6a 100644 --- a/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart +++ b/mobile/lib/providers/asset_viewer/share_intent_upload.provider.dart @@ -29,6 +29,11 @@ class ShareIntentUploadStateNotifier extends StateNotifier attachments) { + if (attachments.any((attachment) => attachment.type == ShareIntentAttachmentType.file)) { + router.removeWhere((route) => route.name == "FileVaultRoute"); + unawaited(router.push(FileVaultRoute(incomingPaths: attachments.map((attachment) => attachment.path).toList()))); + return; + } router.removeWhere((route) => route.name == "ShareIntentRoute"); clearAttachments(); addAttachments(attachments); diff --git a/mobile/lib/providers/tab.provider.dart b/mobile/lib/providers/tab.provider.dart index d523e72c3..fd09bb2b5 100644 --- a/mobile/lib/providers/tab.provider.dart +++ b/mobile/lib/providers/tab.provider.dart @@ -1,6 +1,6 @@ import 'package:hooks_riverpod/hooks_riverpod.dart'; -enum TabEnum { home, search, albums, library } +enum TabEnum { home, search, albums, library, files } /// Provides the currently active tab final tabProvider = StateProvider((ref) => TabEnum.home); diff --git a/mobile/lib/repositories/share_handler.repository.dart b/mobile/lib/repositories/share_handler.repository.dart index 4650b04a7..ade3033b6 100644 --- a/mobile/lib/repositories/share_handler.repository.dart +++ b/mobile/lib/repositories/share_handler.repository.dart @@ -36,7 +36,9 @@ class ShareHandlerRepository { final type = attachment.type == SharedAttachmentType.image ? ShareIntentAttachmentType.image - : ShareIntentAttachmentType.video; + : attachment.type == SharedAttachmentType.video + ? ShareIntentAttachmentType.video + : ShareIntentAttachmentType.file; final fileLength = File(attachment.path).lengthSync(); diff --git a/mobile/lib/routing/router.dart b/mobile/lib/routing/router.dart index 949967081..0e8c3a94a 100644 --- a/mobile/lib/routing/router.dart +++ b/mobile/lib/routing/router.dart @@ -49,6 +49,7 @@ import 'package:immich_mobile/presentation/pages/download_info.page.dart'; import 'package:immich_mobile/presentation/pages/edit/edit.page.dart'; import 'package:immich_mobile/presentation/pages/favorite.page.dart'; import 'package:immich_mobile/presentation/pages/feature_message/whats_new.page.dart'; +import 'package:immich_mobile/presentation/pages/file_vault.page.dart'; import 'package:immich_mobile/presentation/pages/library.page.dart'; import 'package:immich_mobile/presentation/pages/local_album.page.dart'; import 'package:immich_mobile/presentation/pages/local_timeline.page.dart'; @@ -126,8 +127,10 @@ class AppRouter extends RootStackRouter { AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false), AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]), + AutoRoute(page: FileVaultRoute.page, guards: [_authGuard]), ], ), + AutoRoute(page: FileVaultRoute.page, guards: [_authGuard]), AutoRoute(page: ProfilePictureCropRoute.page), AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]), AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), diff --git a/mobile/pigeon/file_vault_api.dart b/mobile/pigeon/file_vault_api.dart new file mode 100644 index 000000000..716c9e8b6 --- /dev/null +++ b/mobile/pigeon/file_vault_api.dart @@ -0,0 +1,28 @@ +import 'package:pigeon/pigeon.dart'; + +class VaultPickedFile { + String path; + String name; + + VaultPickedFile(this.path, this.name); +} + +@ConfigurePigeon( + PigeonOptions( + dartOut: 'lib/platform/file_vault_api.g.dart', + kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/files/FileVaultApi.g.kt', + kotlinOptions: KotlinOptions(package: 'app.alextran.immich.files', includeErrorClass: true), + dartOptions: DartOptions(), + dartPackageName: 'immich_mobile', + ), +) +@HostApi() +abstract class FileVaultPlatform { + @async + List pickFiles(); + + @async + bool saveFile(String sourcePath, String suggestedName); + + void openFile(String sourcePath, String suggestedName); +} diff --git a/mobile/pubspec.yaml b/mobile/pubspec.yaml index 1b5a5a56f..4b331be94 100644 --- a/mobile/pubspec.yaml +++ b/mobile/pubspec.yaml @@ -2,7 +2,7 @@ name: immich_mobile description: Immich - selfhosted backup media file on mobile phone publish_to: 'none' -version: 3.4.0-local.1+3040001 +version: 3.4.0-local.2+3040002 environment: sdk: '>=3.12.0 <4.0.0' diff --git a/mobile/test/unit/file_vault_api_test.dart b/mobile/test/unit/file_vault_api_test.dart new file mode 100644 index 000000000..9dfbd6b57 --- /dev/null +++ b/mobile/test/unit/file_vault_api_test.dart @@ -0,0 +1,163 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:crypto/crypto.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:immich_mobile/data/server/file_vault_api.dart'; +import 'package:immich_mobile/domain/models/file_vault.model.dart'; +import 'package:immich_mobile/platform/file_vault_api.g.dart'; +import 'package:immich_mobile/services/api.service.dart'; +import 'package:openapi/api.dart'; +import 'package:path/path.dart' as p; + +class TestVaultApiService extends Fake implements ApiService { + TestVaultApiService(this.client); + final ApiClient client; + @override + ApiClient get apiClient => client; +} + +void main() { + late Directory temporary; + setUp(() async { + temporary = await Directory.systemTemp.createTemp('immich-file-vault-test-'); + }); + tearDown(() async { + await temporary.delete(recursive: true); + }); + + FileVaultApiData repository(Future Function(http.Request) handler) { + final client = ApiClient(basePath: 'https://immich.example/api')..client = MockClient(handler); + client.addDefaultHeader('Authorization', 'Bearer test-session'); + return FileVaultApiData( + TestVaultApiService(client), + FileVaultPlatform(), + temporaryDirectory: () async => temporary, + ); + } + + Map entry({List bytes = const [], String name = 'Работа.dwg'}) => { + 'id': '00000000-0000-4000-8000-000000000001', + 'parentId': null, + 'name': name, + 'isFolder': false, + 'size': bytes.length, + 'checksum': sha256.convert(bytes).toString(), + 'createdAt': '2026-10-31T00:00:00.000Z', + 'updatedAt': '2026-10-31T00:00:00.000Z', + 'deletedAt': null, + }; + + test('renaming preserves the current folder and moving to root sends explicit null', () async { + final requests = >[]; + final data = repository((request) async { + requests.add(jsonDecode(request.body) as Map); + return http.Response(jsonEncode(entry()), 200, headers: {'content-type': 'application/json; charset=utf-8'}); + }); + await data.rename('00000000-0000-4000-8000-000000000001', 'Renamed.pdf'); + await data.move('00000000-0000-4000-8000-000000000001', null); + expect(requests, [ + {'name': 'Renamed.pdf'}, + {'parentId': null}, + ]); + }); + + test('uploads original binary bytes with the private session and verifies the returned hash', () async { + final bytes = [0, 1, 255, 128, 13, 10, 0]; + final file = File(p.join(temporary.path, 'Работа.dwg')); + await file.writeAsBytes(bytes); + final data = repository((request) async { + expect(request.url.queryParameters['name'], 'Работа.dwg'); + expect(request.headers['Authorization'], 'Bearer test-session'); + expect(request.bodyBytes, bytes); + return http.Response( + jsonEncode(entry(bytes: bytes)), + 201, + headers: {'content-type': 'application/json; charset=utf-8'}, + ); + }); + var progress = 0; + await data.upload(file.path, 'Работа.dwg', null, (sent, total) { + progress = sent; + expect(total, bytes.length); + }); + expect(progress, bytes.length); + }); + + test('reports rejected uploads rather than treating them as successful', () async { + final file = File(p.join(temporary.path, 'private.pdf')); + await file.writeAsString('private'); + final data = repository((_) async => http.Response('{"message":"Name exists"}', 409)); + await expectLater( + data.upload(file.path, 'private.pdf', null, (_, _) {}), + throwsA(isA().having((error) => error.code, 'code', 409)), + ); + }); + + test('does not confirm an upload when the returned original checksum differs', () async { + final file = File(p.join(temporary.path, 'private.pdf')); + await file.writeAsString('private'); + final data = repository( + (_) async => http.Response( + jsonEncode(entry(bytes: [1, 2, 3])), + 201, + headers: {'content-type': 'application/json; charset=utf-8'}, + ), + ); + await expectLater(data.upload(file.path, 'private.pdf', null, (_, _) {}), throwsFormatException); + }); + + test('downloads into private temporary storage and validates original bytes', () async { + final bytes = [255, 0, 1, 13, 10, 128]; + final data = repository((request) async { + expect(request.headers['Authorization'], 'Bearer test-session'); + return http.Response.bytes(bytes, 200); + }); + final file = VaultEntry( + id: '00000000-0000-4000-8000-000000000001', + name: 'Работа.zip', + parentId: null, + isFolder: false, + size: bytes.length, + checksum: sha256.convert(bytes).toString(), + ); + final result = await data.download(file, (_, _) {}); + expect(p.isWithin(temporary.path, result.path), isTrue); + expect(await result.readAsBytes(), bytes); + }); + + test('removes partial downloads when size or checksum verification fails', () async { + final data = repository((_) async => http.Response.bytes([1, 2], 200)); + const file = VaultEntry( + id: '00000000-0000-4000-8000-000000000001', + name: 'broken.pdf', + parentId: null, + isFolder: false, + size: 3, + checksum: 'invalid', + ); + await expectLater(data.download(file, (_, _) {}), throwsFormatException); + expect(await temporary.list(recursive: true).where((entry) => entry is File).toList(), isEmpty); + }); + + test('rejects a server-supplied path traversal identifier before any filesystem or network access', () async { + var requested = false; + final data = repository((_) async { + requested = true; + return http.Response('private', 200); + }); + const file = VaultEntry( + id: '../../outside', + name: 'database', + parentId: null, + isFolder: false, + size: 7, + checksum: null, + ); + await expectLater(data.download(file, (_, _) {}), throwsFormatException); + expect(requested, isFalse); + expect(await temporary.list().toList(), isEmpty); + }); +} diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 06ca91b2f..8b6d02459 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -7229,6 +7229,486 @@ "x-immich-state": "Stable" } }, + "/file-vault": { + "get": { + "description": "List a private folder or trash with storage usage.", + "operationId": "listFiles", + "parameters": [ + { + "name": "offset", + "required": false, + "in": "query", + "schema": { + "minimum": 0, + "maximum": 9007199254740991, + "default": 0, + "type": "integer" + } + }, + { + "name": "parentId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + { + "name": "trash", + "required": false, + "in": "query", + "schema": { + "type": "string", + "enum": [ + "true", + "false" + ] + } + } + ], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVaultListResponseDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List personal files", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, + "/file-vault/folders": { + "get": { + "description": "List available destination folders owned by the current user.", + "operationId": "listFileFolders", + "parameters": [], + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/FileVaultFolderDto" + }, + "type": "array" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "List personal folders", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + }, + "post": { + "description": "Create a folder without overwriting existing names.", + "operationId": "createFileFolder", + "parameters": [], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVaultCreateFolderDto" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVaultEntryDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Create a personal folder", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, + "/file-vault/upload": { + "post": { + "description": "Stream an original file into private storage without media processing or overwriting.", + "operationId": "uploadFile", + "parameters": [ + { + "name": "name", + "required": true, + "in": "query", + "schema": { + "minLength": 1, + "maxLength": 240, + "type": "string" + } + }, + { + "name": "parentId", + "required": false, + "in": "query", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVaultEntryDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Upload a personal file", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, + "/file-vault/{id}": { + "delete": { + "description": "Move an item and its active descendants to the private trash.", + "operationId": "trashFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Trash a personal item", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + }, + "patch": { + "description": "Rename or move a file or folder while preserving the original contents.", + "operationId": "updateFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVaultUpdateDto" + } + } + }, + "required": true + }, + "responses": { + "200": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FileVaultEntryDto" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Rename or move a personal item", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, + "/file-vault/{id}/download": { + "get": { + "description": "Download original bytes as an attachment after verifying ownership.", + "operationId": "downloadFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "200": { + "content": { + "application/octet-stream": { + "schema": { + "format": "binary", + "type": "string" + } + } + }, + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Download a personal file", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, + "/file-vault/{id}/permanent": { + "delete": { + "description": "Permanently remove a trashed item and its contents.", + "operationId": "permanentlyDeleteFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Permanently delete a personal item", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, + "/file-vault/{id}/restore": { + "post": { + "description": "Restore a trashed item with its contents, or restore to the root if its parent is unavailable.", + "operationId": "restoreFile", + "parameters": [ + { + "name": "id", + "required": true, + "in": "path", + "schema": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$", + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "" + } + }, + "security": [ + { + "bearer": [] + }, + { + "cookie": [] + }, + { + "api_key": [] + } + ], + "summary": "Restore a personal item", + "tags": [ + "File Vault" + ], + "x-immich-history": [ + { + "version": "v3.4.0", + "state": "Added" + } + ] + } + }, "/futo-backups/auth/oidc/device": { "get": { "description": "Connect an existing FUTO Backups account to this server.", @@ -25965,6 +26445,154 @@ ], "type": "object" }, + "FileVaultCreateFolderDto": { + "properties": { + "name": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "FileVaultEntryDto": { + "properties": { + "checksum": { + "nullable": true, + "type": "string" + }, + "createdAt": { + "type": "string" + }, + "deletedAt": { + "nullable": true, + "type": "string" + }, + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "isFolder": { + "type": "boolean" + }, + "name": { + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "size": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "updatedAt": { + "type": "string" + } + }, + "required": [ + "checksum", + "createdAt", + "deletedAt", + "id", + "isFolder", + "name", + "parentId", + "size", + "updatedAt" + ], + "type": "object" + }, + "FileVaultFolderDto": { + "properties": { + "id": { + "format": "uuid", + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + }, + "name": { + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "required": [ + "id", + "name", + "parentId" + ], + "type": "object" + }, + "FileVaultListResponseDto": { + "properties": { + "breadcrumbs": { + "items": { + "$ref": "#/components/schemas/FileVaultFolderDto" + }, + "type": "array" + }, + "entries": { + "items": { + "$ref": "#/components/schemas/FileVaultEntryDto" + }, + "type": "array" + }, + "hasMore": { + "type": "boolean" + }, + "maxFileBytes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + }, + "usedBytes": { + "maximum": 9007199254740991, + "minimum": -9007199254740991, + "type": "integer" + } + }, + "required": [ + "breadcrumbs", + "entries", + "hasMore", + "maxFileBytes", + "usedBytes" + ], + "type": "object" + }, + "FileVaultUpdateDto": { + "properties": { + "name": { + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "parentId": { + "format": "uuid", + "nullable": true, + "pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$", + "type": "string" + } + }, + "type": "object" + }, "FilesystemListingItemDto": { "properties": { "isDirectory": { @@ -29329,6 +29957,7 @@ "Permission": { "description": "List of permissions", "enum": [ + "fileVault.read", "all", "activity.create", "activity.read", diff --git a/packages/sdk/src/fetch-client.ts b/packages/sdk/src/fetch-client.ts index 071d4825e..aeadc4590 100644 --- a/packages/sdk/src/fetch-client.ts +++ b/packages/sdk/src/fetch-client.ts @@ -1772,6 +1772,37 @@ export type FaceDto = { /** Face ID */ id: string; }; +export type FileVaultFolderDto = { + id: string; + name: string; + parentId: string | null; +}; +export type FileVaultEntryDto = { + checksum: string | null; + createdAt: string; + deletedAt: string | null; + id: string; + isFolder: boolean; + name: string; + parentId: string | null; + size: number; + updatedAt: string; +}; +export type FileVaultListResponseDto = { + breadcrumbs: FileVaultFolderDto[]; + entries: FileVaultEntryDto[]; + hasMore: boolean; + maxFileBytes: number; + usedBytes: number; +}; +export type FileVaultCreateFolderDto = { + name: string; + parentId?: string | null; +}; +export type FileVaultUpdateDto = { + name?: string; + parentId?: string | null; +}; export type DeviceFlowEventDto = { backendId?: string; reason?: DeviceFlowFailureReason; @@ -5986,6 +6017,133 @@ export function reassignFacesById({ id, faceDto }: { body: faceDto }))); } +/** + * List personal files + */ +export function listFiles({ offset, parentId, trash }: { + offset?: number; + parentId?: string; + trash?: "true" | "false"; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FileVaultListResponseDto; + }>(`/file-vault${QS.query(QS.explode({ + offset, + parentId, + trash + }))}`, { + ...opts + })); +} +/** + * List personal folders + */ +export function listFileFolders(opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FileVaultFolderDto[]; + }>("/file-vault/folders", { + ...opts + })); +} +/** + * Create a personal folder + */ +export function createFileFolder({ fileVaultCreateFolderDto }: { + fileVaultCreateFolderDto: FileVaultCreateFolderDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: FileVaultEntryDto; + }>("/file-vault/folders", oazapfts.json({ + ...opts, + method: "POST", + body: fileVaultCreateFolderDto + }))); +} +/** + * Upload a personal file + */ +export function uploadFile({ name, parentId, body }: { + name: string; + parentId?: string; + body: Blob; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 201; + data: FileVaultEntryDto; + }>(`/file-vault/upload${QS.query(QS.explode({ + name, + parentId + }))}`, { + ...opts, + method: "POST", + body + })); +} +/** + * Trash a personal item + */ +export function trashFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/file-vault/${encodeURIComponent(id)}`, { + ...opts, + method: "DELETE" + })); +} +/** + * Rename or move a personal item + */ +export function updateFile({ id, fileVaultUpdateDto }: { + id: string; + fileVaultUpdateDto: FileVaultUpdateDto; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchJson<{ + status: 200; + data: FileVaultEntryDto; + }>(`/file-vault/${encodeURIComponent(id)}`, oazapfts.json({ + ...opts, + method: "PATCH", + body: fileVaultUpdateDto + }))); +} +/** + * Download a personal file + */ +export function downloadFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchBlob<{ + status: 200; + data: Blob; + }>(`/file-vault/${encodeURIComponent(id)}/download`, { + ...opts + })); +} +/** + * Permanently delete a personal item + */ +export function permanentlyDeleteFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/file-vault/${encodeURIComponent(id)}/permanent`, { + ...opts, + method: "DELETE" + })); +} +/** + * Restore a personal item + */ +export function restoreFile({ id }: { + id: string; +}, opts?: Oazapfts.RequestOpts) { + return oazapfts.ok(oazapfts.fetchText(`/file-vault/${encodeURIComponent(id)}/restore`, { + ...opts, + method: "POST" + })); +} /** * Connect a FUTO Backups account */ @@ -9338,6 +9496,7 @@ export enum BulkIdErrorReason { Validation = "validation" } export enum Permission { + FileVaultRead = "fileVault.read", All = "all", ActivityCreate = "activity.create", ActivityRead = "activity.read", diff --git a/server/src/controllers/file-vault.controller.spec.ts b/server/src/controllers/file-vault.controller.spec.ts new file mode 100644 index 000000000..63aa1a560 --- /dev/null +++ b/server/src/controllers/file-vault.controller.spec.ts @@ -0,0 +1,69 @@ +import request from 'supertest'; +import { FileVaultController } from 'src/controllers/file-vault.controller.js'; +import { FileVaultService } from 'src/services/file-vault.service.js'; +import { factory } from 'test/small.factory.js'; +import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js'; + +describe(FileVaultController.name, () => { + let ctx: ControllerContext; + const service = mockBaseService(FileVaultService); + beforeAll(async () => { + ctx = await controllerSetup(FileVaultController, [{ provide: FileVaultService, useValue: service }]); + return () => ctx.close(); + }); + beforeEach(() => { + service.resetAllMocks(); + ctx.reset(); + }); + + it.each(['../secret', 'work/file', String.raw`work\file`, '.', '..', '\0x', 'x\ny', '', 'я'.repeat(121)])( + 'rejects unsafe names: %j', + async (name) => { + const response = await request(ctx.getHttpServer()).post('/file-vault/folders').send({ name }); + expect(response.status).toBe(400); + expect(service.createFolder).not.toHaveBeenCalled(); + }, + ); + + it('accepts Unicode folder names and a root destination', async () => { + const response = await request(ctx.getHttpServer()) + .post('/file-vault/folders') + .send({ name: 'Рабочие чертежи', parentId: null }); + expect(response.status).toBe(201); + expect(service.createFolder).toHaveBeenCalledWith(undefined, { name: 'Рабочие чертежи', parentId: null }); + }); + + it.each(['-1', '1.5', 'no'])('rejects invalid pagination: %s', async (offset) => { + const response = await request(ctx.getHttpServer()).get('/file-vault').query({ offset }); + expect(response.status).toBe(400); + expect(service.list).not.toHaveBeenCalled(); + }); + + it('preserves an explicit root move', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).patch(`/file-vault/${id}`).send({ parentId: null }); + expect(service.update).toHaveBeenCalledWith(undefined, id, { parentId: null }); + }); + + it('does not turn a rename into a move', async () => { + const id = factory.uuid(); + await request(ctx.getHttpServer()).patch(`/file-vault/${id}`).send({ name: 'Report.pdf' }); + expect(service.update).toHaveBeenCalledWith(undefined, id, { name: 'Report.pdf' }); + }); + + it('rejects a malformed download identifier', async () => { + const response = await request(ctx.getHttpServer()).get('/file-vault/invalid/download'); + expect(response.status).toBe(400); + expect(service.download).not.toHaveBeenCalled(); + }); + + it('routes restore and permanent deletion separately', async () => { + const id = factory.uuid(); + const restored = await request(ctx.getHttpServer()).post(`/file-vault/${id}/restore`); + expect(restored.status).toBe(204); + expect(service.restore).toHaveBeenCalledWith(undefined, id); + const removed = await request(ctx.getHttpServer()).delete(`/file-vault/${id}/permanent`); + expect(removed.status).toBe(204); + expect(service.purge).toHaveBeenCalledWith(undefined, id); + }); +}); diff --git a/server/src/controllers/file-vault.controller.ts b/server/src/controllers/file-vault.controller.ts new file mode 100644 index 000000000..e29af928d --- /dev/null +++ b/server/src/controllers/file-vault.controller.ts @@ -0,0 +1,140 @@ +import { Body, Controller, Delete, Get, Header, HttpCode, Param, Patch, Post, Query, Req } from '@nestjs/common'; +import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger'; +import type { Request } from 'express'; +import type { AuthDto } from 'src/dtos/auth.dto.js'; +import { Endpoint, HistoryBuilder } from 'src/decorators.js'; +import { + FileVaultCreateFolderDto, + FileVaultEntryDto, + FileVaultFolderDto, + FileVaultListDto, + FileVaultListResponseDto, + FileVaultUpdateDto, + FileVaultUploadDto, +} from 'src/dtos/file-vault.dto.js'; +import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard.js'; +import { FileVaultService } from 'src/services/file-vault.service.js'; +import { UUIDParamDto } from 'src/validation.js'; + +@ApiTags('File Vault') +@Controller('file-vault') +export class FileVaultController { + constructor(private service: FileVaultService) {} + + @Get() + @Header('Cache-Control', 'private, no-store') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'List personal files', + description: 'List a private folder or trash with storage usage.', + history: new HistoryBuilder().added('v3.4.0'), + }) + listFiles(@Auth() auth: AuthDto, @Query() dto: FileVaultListDto): Promise { + return this.service.list(auth, dto); + } + + @Get('folders') + @Header('Cache-Control', 'private, no-store') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'List personal folders', + description: 'List available destination folders owned by the current user.', + history: new HistoryBuilder().added('v3.4.0'), + }) + listFileFolders(@Auth() auth: AuthDto): Promise { + return this.service.folders(auth); + } + + @Post('folders') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'Create a personal folder', + description: 'Create a folder without overwriting existing names.', + history: new HistoryBuilder().added('v3.4.0'), + }) + createFileFolder(@Auth() auth: AuthDto, @Body() dto: FileVaultCreateFolderDto): Promise { + return this.service.createFolder(auth, dto); + } + + @Post('upload') + @Authenticated({ permission: false }) + @ApiConsumes('application/octet-stream') + @ApiBody({ schema: { type: 'string', format: 'binary' } }) + @Endpoint({ + summary: 'Upload a personal file', + description: 'Stream an original file into private storage without media processing or overwriting.', + history: new HistoryBuilder().added('v3.4.0'), + }) + uploadFile( + @Auth() auth: AuthDto, + @Query() dto: FileVaultUploadDto, + @Req() request: Request, + ): Promise { + return this.service.upload(auth, dto, request, request.headers['content-type'] ?? ''); + } + + @Get(':id/download') + @Header('Cache-Control', 'private, no-store') + @Header('X-Content-Type-Options', 'nosniff') + @Authenticated({ permission: false }) + @FileResponse() + @Endpoint({ + summary: 'Download a personal file', + description: 'Download original bytes as an attachment after verifying ownership.', + history: new HistoryBuilder().added('v3.4.0'), + }) + downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { + return this.service.download(auth, id); + } + + @Patch(':id') + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'Rename or move a personal item', + description: 'Rename or move a file or folder while preserving the original contents.', + history: new HistoryBuilder().added('v3.4.0'), + }) + updateFile( + @Auth() auth: AuthDto, + @Param() { id }: UUIDParamDto, + @Body() dto: FileVaultUpdateDto, + ): Promise { + return this.service.update(auth, id, dto); + } + + @Delete(':id') + @HttpCode(204) + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'Trash a personal item', + description: 'Move an item and its active descendants to the private trash.', + history: new HistoryBuilder().added('v3.4.0'), + }) + trashFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { + return this.service.trash(auth, id); + } + + @Post(':id/restore') + @HttpCode(204) + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'Restore a personal item', + description: 'Restore a trashed item with its contents, or restore to the root if its parent is unavailable.', + history: new HistoryBuilder().added('v3.4.0'), + }) + restoreFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { + return this.service.restore(auth, id); + } + + @Delete(':id/permanent') + @HttpCode(204) + @Authenticated({ permission: false }) + @Endpoint({ + summary: 'Permanently delete a personal item', + description: 'Permanently remove a trashed item and its contents.', + history: new HistoryBuilder().added('v3.4.0'), + }) + permanentlyDeleteFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) { + return this.service.purge(auth, id); + } +} diff --git a/server/src/controllers/index.ts b/server/src/controllers/index.ts index b34848026..4f5f0a505 100644 --- a/server/src/controllers/index.ts +++ b/server/src/controllers/index.ts @@ -15,6 +15,7 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro import { DownloadController } from 'src/controllers/download.controller.js'; import { DuplicateController } from 'src/controllers/duplicate.controller.js'; import { FaceController } from 'src/controllers/face.controller.js'; +import { FileVaultController } from 'src/controllers/file-vault.controller.js'; import { IntegrityAdminController } from 'src/controllers/integrity-admin.controller.js'; import { JobController } from 'src/controllers/job.controller.js'; import { LibraryController } from 'src/controllers/library.controller.js'; @@ -47,6 +48,7 @@ import { ViewController } from 'src/controllers/view.controller.js'; import { WorkflowController } from 'src/controllers/workflow.controller.js'; export const controllers = [ + FileVaultController, ApiKeyController, ActivityController, AlbumController, diff --git a/server/src/dtos/file-vault.dto.ts b/server/src/dtos/file-vault.dto.ts new file mode 100644 index 000000000..01822912c --- /dev/null +++ b/server/src/dtos/file-vault.dto.ts @@ -0,0 +1,69 @@ +import { createZodDto } from 'nestjs-zod'; +import z from 'zod'; + +export const fileVaultName = z + .string() + .min(1) + .max(240) + .refine( + (name) => + name.trim().length > 0 && + !name.includes('/') && + !name.includes('\\') && + [...name].every((char) => { + const code = char.codePointAt(0) ?? 0; + return code >= 32 && code !== 127; + }) && + !['.', '..'].includes(name) && + Buffer.byteLength(name, 'utf8') <= 240, + 'Invalid file name', + ); +const folderId = z.uuid().nullable().optional(); +const entry = z + .object({ + id: z.uuid(), + parentId: z.uuid().nullable(), + name: z.string(), + isFolder: z.boolean(), + size: z.int(), + checksum: z.string().nullable(), + createdAt: z.string(), + updatedAt: z.string(), + deletedAt: z.string().nullable(), + }) + .meta({ id: 'FileVaultEntryDto' }); +const folder = z + .object({ id: z.uuid(), name: z.string(), parentId: z.uuid().nullable() }) + .meta({ id: 'FileVaultFolderDto' }); + +export class FileVaultEntryDto extends createZodDto(entry) {} +export class FileVaultFolderDto extends createZodDto(folder) {} +export class FileVaultCreateFolderDto extends createZodDto( + z.object({ name: fileVaultName, parentId: folderId }).meta({ id: 'FileVaultCreateFolderDto' }), +) {} +export class FileVaultUpdateDto extends createZodDto( + z.object({ name: fileVaultName.optional(), parentId: folderId }).meta({ id: 'FileVaultUpdateDto' }), +) {} +export class FileVaultUploadDto extends createZodDto( + z.object({ name: fileVaultName, parentId: z.uuid().optional() }).meta({ id: 'FileVaultUploadDto' }), +) {} +export class FileVaultListDto extends createZodDto( + z + .object({ + parentId: z.uuid().optional(), + trash: z.enum(['true', 'false']).optional(), + offset: z.coerce.number().int().min(0).default(0), + }) + .meta({ id: 'FileVaultListDto' }), +) {} +export class FileVaultListResponseDto extends createZodDto( + z + .object({ + entries: z.array(entry), + breadcrumbs: z.array(folder), + hasMore: z.boolean(), + usedBytes: z.int(), + maxFileBytes: z.int(), + }) + .meta({ id: 'FileVaultListResponseDto' }), +) {} diff --git a/server/src/enum.ts b/server/src/enum.ts index 6231d63c3..920dcf919 100644 --- a/server/src/enum.ts +++ b/server/src/enum.ts @@ -131,6 +131,7 @@ export const AssetOrderWithRandomSchema = z .meta({ id: 'MemorySearchOrder' }); export enum Permission { + FileVaultRead = 'fileVault.read', All = 'all', ActivityCreate = 'activity.create', diff --git a/server/src/queries/user.repository.sql b/server/src/queries/user.repository.sql index de5cfea13..dfe5728b7 100644 --- a/server/src/queries/user.repository.sql +++ b/server/src/queries/user.repository.sql @@ -399,6 +399,13 @@ set where "asset"."libraryId" is null and "asset"."ownerId" = "user"."id" + ) + ( + select + coalesce(sum("size"), 0) as "usage" + from + "file_vault" + where + "file_vault"."ownerId" = "user"."id" ), "updatedAt" = $1 where diff --git a/server/src/repositories/file-vault.repository.ts b/server/src/repositories/file-vault.repository.ts new file mode 100644 index 000000000..6e8c1d0da --- /dev/null +++ b/server/src/repositories/file-vault.repository.ts @@ -0,0 +1,401 @@ +import { Injectable } from '@nestjs/common'; +import { ZipArchive } from 'archiver'; +import { type Insertable, type Kysely, type Selectable, type Transaction, sql } from 'kysely'; +import { InjectKysely } from 'nestjs-kysely'; +import { createHash, randomUUID } from 'node:crypto'; +import { createReadStream, createWriteStream } from 'node:fs'; +import { mkdir, open, rename, rm, statfs } from 'node:fs/promises'; +import { dirname, join } from 'node:path'; +import { Readable, Transform } from 'node:stream'; +import { pipeline } from 'node:stream/promises'; +import { StorageCore } from 'src/cores/storage.core.js'; +import { FileVaultListDto, FileVaultUpdateDto } from 'src/dtos/file-vault.dto.js'; +import { DB } from 'src/schema/index.js'; +import { FileVaultTable } from 'src/schema/tables/file-vault.table.js'; + +export const FILE_VAULT_MAX_BYTES = 20 * 1024 ** 3; +export type VaultEntry = Selectable; +export class FileVaultFailure extends Error { + constructor( + public code: 'missing' | 'conflict' | 'invalid' | 'quota' | 'space', + message: string, + ) { + super(message); + } +} + +@Injectable() +export class FileVaultRepository { + constructor(@InjectKysely() private db: Kysely) {} + + private path(ownerId: string, id: string) { + return join(StorageCore.getMediaLocation(), 'files', ownerId, id); + } + + private async locked(ownerId: string, handler: (tx: Transaction) => Promise) { + return this.db.transaction().execute(async (tx) => { + await tx.selectFrom('user').select('id').where('id', '=', ownerId).forUpdate().executeTakeFirstOrThrow(); + return handler(tx); + }); + } + + private async get(db: Kysely, ownerId: string, id: string) { + const entry = await db + .selectFrom('file_vault') + .selectAll() + .where('ownerId', '=', ownerId) + .where('id', '=', id) + .executeTakeFirst(); + if (!entry) { + throw new FileVaultFailure('missing', 'File or folder not found'); + } + return entry; + } + + private async ancestors(db: Kysely, ownerId: string, id: string | null, excluded?: string) { + const entries: VaultEntry[] = []; + const seen = new Set(); + const excludedId = excluded?.toLowerCase(); + while (id) { + const entry = await this.get(db, ownerId, id); + if (seen.has(entry.id) || entry.id === excludedId || entries.length >= 128) { + throw new FileVaultFailure('invalid', 'Cannot move a folder into itself or its descendants'); + } + seen.add(entry.id); + if (!entry.isFolder || entry.deletedAt) { + throw new FileVaultFailure('missing', 'Destination folder is unavailable'); + } + entries.unshift(entry); + id = entry.parentId; + } + return entries; + } + + private async available(db: Kysely, ownerId: string, parentId: string | null, name: string, except?: string) { + const duplicate = await db + .selectFrom('file_vault') + .select('id') + .where('ownerId', '=', ownerId) + .where('parentId', parentId ? '=' : 'is', parentId) + .where('deletedAt', 'is', null) + .where(sql`lower(name)`, '=', name.toLowerCase()) + .$if(!!except, (q) => q.where('id', '!=', except!)) + .executeTakeFirst(); + if (duplicate) { + throw new FileVaultFailure('conflict', 'A file or folder with this name already exists'); + } + } + + async list(ownerId: string, dto: FileVaultListDto) { + return this.locked(ownerId, async (tx) => { + const breadcrumbs = dto.trash === 'true' ? [] : await this.ancestors(tx, ownerId, dto.parentId ?? null); + let query = tx.selectFrom('file_vault').selectAll().where('ownerId', '=', ownerId); + query = + dto.trash === 'true' + ? query.where('deletedAt', 'is not', null).whereRef('trashRootId', '=', 'id') + : query.where('deletedAt', 'is', null).where('parentId', dto.parentId ? '=' : 'is', dto.parentId ?? null); + const entries = await query + .orderBy('isFolder', 'desc') + .orderBy('name') + .orderBy('id') + .offset(dto.offset) + .limit(101) + .execute(); + const usage = await tx + .selectFrom('file_vault') + .select((eb) => eb.fn.sum('size').as('size')) + .where('ownerId', '=', ownerId) + .executeTakeFirst(); + return { + entries: entries.slice(0, 100), + breadcrumbs, + hasMore: entries.length > 100, + usedBytes: Number(usage?.size ?? 0), + maxFileBytes: FILE_VAULT_MAX_BYTES, + }; + }); + } + + async folders(ownerId: string) { + const result = await sql`WITH RECURSIVE folders AS ( + SELECT * FROM file_vault WHERE "ownerId"=${ownerId} AND "parentId" IS NULL AND "isFolder" AND "deletedAt" IS NULL + UNION ALL SELECT f.* FROM file_vault f JOIN folders p ON f."parentId"=p.id + WHERE f."ownerId"=${ownerId} AND f."isFolder" AND f."deletedAt" IS NULL + ) SELECT * FROM folders ORDER BY name`.execute(this.db); + return result.rows; + } + + async createFolder(ownerId: string, name: string, parentId: string | null) { + return this.locked(ownerId, async (tx) => { + const parents = await this.ancestors(tx, ownerId, parentId); + if (parents.length >= 128) { + throw new FileVaultFailure('invalid', 'Folder nesting limit exceeded'); + } + await this.available(tx, ownerId, parentId, name); + return tx + .insertInto('file_vault') + .values({ ownerId, parentId, name, isFolder: true, checksum: null, deletedAt: null, trashRootId: null }) + .returningAll() + .executeTakeFirstOrThrow(); + }); + } + + async upload(ownerId: string, name: string, parentId: string | null, source: Readable) { + await this.ancestors(this.db, ownerId, parentId); + await this.available(this.db, ownerId, parentId, name); + const id = randomUUID(); + const path = this.path(ownerId, id); + const temporary = path + '.part'; + await mkdir(join(StorageCore.getMediaLocation(), 'files', ownerId), { recursive: true, mode: 0o700 }); + const disk = await statfs(StorageCore.getMediaLocation()); + const budget = Math.min(FILE_VAULT_MAX_BYTES, disk.bavail * disk.bsize - 100 * 1024 ** 2); + let size = 0; + let stored = false; + const hash = createHash('sha256'); + try { + await pipeline( + source, + new Transform({ + transform(chunk: Buffer, _, callback) { + size += chunk.length; + if (size > budget) { + callback(new FileVaultFailure('space', 'File exceeds the upload limit or available disk space')); + return; + } + hash.update(chunk); + callback(null, chunk); + }, + }), + createWriteStream(temporary, { flags: 'wx', mode: 0o600 }), + ); + const handle = await open(temporary, 'r+'); + try { + await handle.sync(); + } finally { + await handle.close(); + } + const checksum = hash.digest('hex'); + return await this.locked(ownerId, async (tx) => { + await this.ancestors(tx, ownerId, parentId); + await this.available(tx, ownerId, parentId, name); + const user = await tx + .selectFrom('user') + .select(['quotaUsageInBytes', 'quotaSizeInBytes']) + .where('id', '=', ownerId) + .executeTakeFirstOrThrow(); + if (user.quotaSizeInBytes !== null && Number(user.quotaUsageInBytes) + size > Number(user.quotaSizeInBytes)) { + throw new FileVaultFailure('quota', 'Storage quota exceeded'); + } + await rename(temporary, path); + stored = true; + if (process.platform !== 'win32') { + const directory = await open(dirname(path), 'r'); + try { + await directory.sync(); + } finally { + await directory.close(); + } + } + const entry: Insertable = { + id, + ownerId, + parentId, + name, + isFolder: false, + size, + checksum, + deletedAt: null, + trashRootId: null, + }; + const saved = await tx.insertInto('file_vault').values(entry).returningAll().executeTakeFirstOrThrow(); + await tx + .updateTable('user') + .set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` }) + .where('id', '=', ownerId) + .execute(); + return saved; + }); + } catch (error) { + await rm(temporary, { force: true }); + if (stored) { + let verified = false; + const committed = await this.db + .selectFrom('file_vault') + .selectAll() + .where('ownerId', '=', ownerId) + .where('id', '=', id) + .executeTakeFirst() + .then((row) => { + verified = true; + return row; + }) + .catch(() => {}); + if (committed) { + return committed; + } + if (verified) { + await rm(path, { force: true }); + } + } + throw error; + } + } + + async download(ownerId: string, id: string) { + const entry = await this.get(this.db, ownerId, id); + if (entry.deletedAt) { + throw new FileVaultFailure('missing', 'File not found'); + } + await this.ancestors(this.db, ownerId, entry.parentId); + if (entry.isFolder) { + const tree = await sql`WITH RECURSIVE tree AS ( + SELECT f.*, ARRAY[f.name]::text[] AS path FROM file_vault f WHERE f.id=${id} AND f."ownerId"=${ownerId} AND f."deletedAt" IS NULL + UNION ALL SELECT f.*, t.path || f.name::text FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId} AND f."deletedAt" IS NULL + ) SELECT * FROM tree`.execute(this.db); + const archive = new ZipArchive({ store: true }); + archive.on('warning', (error: Error) => archive.destroy(error)); + for (const item of tree.rows) { + const name = item.path.join('/'); + if (item.isFolder) { + archive.append('', { name: `${name}/`, mode: 0o755 }); + } else { + archive.file(this.path(ownerId, item.id), { name, mode: 0o644 }); + } + } + setImmediate(() => { + void archive.finalize().catch((error: Error) => archive.destroy(error)); + }); + return { entry, stream: archive }; + } + return { entry, stream: createReadStream(this.path(ownerId, entry.id)) }; + } + + removeUserFiles(ownerId: string) { + return rm(join(StorageCore.getMediaLocation(), 'files', ownerId), { recursive: true, force: true }); + } + + async update(ownerId: string, id: string, dto: FileVaultUpdateDto) { + return this.locked(ownerId, async (tx) => { + const entry = await this.get(tx, ownerId, id); + const parentId = dto.parentId === undefined ? entry.parentId : dto.parentId; + if (entry.deletedAt && dto.parentId !== undefined) { + throw new FileVaultFailure('invalid', 'Restore the item before moving it'); + } + if (!entry.deletedAt) { + const parents = await this.ancestors(tx, ownerId, parentId, entry.isFolder ? entry.id : undefined); + if (entry.isFolder && dto.parentId !== undefined) { + const height = await sql<{ depth: number }>`WITH RECURSIVE tree AS ( + SELECT id, 1 AS depth FROM file_vault WHERE id=${entry.id} AND "ownerId"=${ownerId} + UNION ALL SELECT f.id, t.depth+1 FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId} + ) SELECT max(depth)::int AS depth FROM tree`.execute(tx); + if (parents.length + height.rows[0].depth > 128) { + throw new FileVaultFailure('invalid', 'Folder nesting limit exceeded'); + } + } + await this.available(tx, ownerId, parentId, dto.name ?? entry.name, id); + } + return tx + .updateTable('file_vault') + .set({ name: dto.name ?? entry.name, parentId }) + .where('id', '=', id) + .where('ownerId', '=', ownerId) + .returningAll() + .executeTakeFirstOrThrow(); + }); + } + + async trash(ownerId: string, id: string) { + return this.locked(ownerId, async (tx) => { + const entry = await this.get(tx, ownerId, id); + if (entry.deletedAt) { + throw new FileVaultFailure('invalid', 'Item is already in the trash'); + } + await this.ancestors(tx, ownerId, entry.parentId); + await sql`WITH RECURSIVE tree AS ( + SELECT id FROM file_vault WHERE id=${id} AND "ownerId"=${ownerId} + UNION ALL SELECT f.id FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId} + ) UPDATE file_vault SET "deletedAt"=now(), "trashRootId"=${id}::uuid WHERE id IN (SELECT id FROM tree) AND "deletedAt" IS NULL`.execute( + tx, + ); + }); + } + + async restore(ownerId: string, id: string) { + return this.locked(ownerId, async (tx) => { + const entry = await this.get(tx, ownerId, id); + if (!entry.deletedAt || entry.trashRootId !== entry.id) { + throw new FileVaultFailure('invalid', 'Select a root item in the trash'); + } + let parentId = entry.parentId; + try { + await this.ancestors(tx, ownerId, parentId); + } catch (error) { + if (!(error instanceof FileVaultFailure)) { + throw error; + } + parentId = null; + } + await this.available(tx, ownerId, parentId, entry.name); + await tx + .updateTable('file_vault') + .set({ parentId }) + .where('id', '=', id) + .where('ownerId', '=', ownerId) + .execute(); + await tx + .updateTable('file_vault') + .set({ deletedAt: null, trashRootId: null }) + .where('ownerId', '=', ownerId) + .where('trashRootId', '=', id) + .execute(); + }); + } + + async purge(ownerId: string, id: string) { + await this.locked(ownerId, async (tx) => { + const entry = await this.get(tx, ownerId, id); + if (!entry.deletedAt || entry.trashRootId !== entry.id) { + throw new FileVaultFailure('invalid', 'Only items in the trash can be permanently deleted'); + } + const tree = await sql`WITH RECURSIVE tree AS ( + SELECT * FROM file_vault WHERE id=${id} AND "ownerId"=${ownerId} + UNION ALL SELECT f.* FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId} + ) SELECT * FROM tree`.execute(tx); + const files = tree.rows.filter((file) => !file.isFolder).map((file) => ({ id: file.id, ownerId })); + if (files.length > 0) { + await tx + .insertInto('file_vault_delete') + .values(files) + .onConflict((oc) => oc.column('id').doNothing()) + .execute(); + } + const size = tree.rows.reduce((total, row) => total + Number(row.size), 0); + await tx.deleteFrom('file_vault').where('ownerId', '=', ownerId).where('id', '=', id).execute(); + await tx + .updateTable('user') + .set({ quotaUsageInBytes: sql`GREATEST(0, "quotaUsageInBytes" - ${size})` }) + .where('id', '=', ownerId) + .execute(); + }); + return this.flushDeletes(ownerId); + } + + async flushDeletes(ownerId?: string) { + const entries = await this.db + .selectFrom('file_vault_delete') + .selectAll() + .$if(!!ownerId, (q) => q.where('ownerId', '=', ownerId!)) + .limit(1000) + .execute(); + const failed: string[] = []; + for (const entry of entries) { + try { + await rm(this.path(entry.ownerId, entry.id), { force: true }); + await this.db.deleteFrom('file_vault_delete').where('id', '=', entry.id).execute(); + } catch { + failed.push(entry.id); + } + } + return failed; + } +} diff --git a/server/src/repositories/index.ts b/server/src/repositories/index.ts index 303852db6..2a8c9ae64 100644 --- a/server/src/repositories/index.ts +++ b/server/src/repositories/index.ts @@ -18,6 +18,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js'; import { EventRepository } from 'src/repositories/event.repository.js'; +import { FileVaultRepository } from 'src/repositories/file-vault.repository.js'; import { IntegrityRepository } from 'src/repositories/integrity.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js'; @@ -104,6 +105,7 @@ export const repositories = [ SharedLinkRepository, SharedLinkAssetRepository, StackRepository, + FileVaultRepository, StorageRepository, SyncRepository, SyncCheckpointRepository, diff --git a/server/src/repositories/user.repository.ts b/server/src/repositories/user.repository.ts index 5d692f82d..7377e1c5a 100644 --- a/server/src/repositories/user.repository.ts +++ b/server/src/repositories/user.repository.ts @@ -312,12 +312,19 @@ export class UserRepository { .updateTable('user') .set({ quotaUsageInBytes: (eb) => - eb - .selectFrom('asset') - .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') - .select((eb) => eb.fn.coalesce(eb.fn.sum('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage')) - .where('asset.libraryId', 'is', null) - .where('asset.ownerId', '=', eb.ref('user.id')), + eb( + eb + .selectFrom('asset') + .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') + .select((eb) => eb.fn.coalesce(eb.fn.sum('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage')) + .where('asset.libraryId', 'is', null) + .where('asset.ownerId', '=', eb.ref('user.id')), + '+', + eb + .selectFrom('file_vault') + .select((eb) => eb.fn.coalesce(eb.fn.sum('size'), eb.lit(0)).as('usage')) + .where('file_vault.ownerId', '=', eb.ref('user.id')), + ).$notNull(), updatedAt: new Date(), }) .where('user.deletedAt', 'is', null) diff --git a/server/src/schema/index.ts b/server/src/schema/index.ts index 0bd0a197a..251bc1af5 100644 --- a/server/src/schema/index.ts +++ b/server/src/schema/index.ts @@ -54,6 +54,7 @@ import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js'; import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table.js'; import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js'; import { FaceSearchTable } from 'src/schema/tables/face-search.table.js'; +import { FileVaultDeleteTable, FileVaultTable } from 'src/schema/tables/file-vault.table.js'; import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table.js'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table.js'; import { LibraryTable } from 'src/schema/tables/library.table.js'; @@ -104,6 +105,8 @@ import { WorkflowTable } from 'src/schema/tables/workflow.table.js'; @Database({ name: 'immich' }) export class ImmichDatabase { tables = [ + FileVaultTable, + FileVaultDeleteTable, ActivityTable, AlbumAssetTable, AlbumAssetAuditTable, @@ -212,6 +215,8 @@ export interface Migrations { } export interface DB { + file_vault: FileVaultTable; + file_vault_delete: FileVaultDeleteTable; kysely_migrations: { timestamp: string; name: string }; activity: ActivityTable; diff --git a/server/src/schema/migrations/1793471628509-AddFileVault.ts b/server/src/schema/migrations/1793471628509-AddFileVault.ts new file mode 100644 index 000000000..3965cf2c7 --- /dev/null +++ b/server/src/schema/migrations/1793471628509-AddFileVault.ts @@ -0,0 +1,45 @@ +import { Kysely, sql } from 'kysely'; + +export async function up(db: Kysely): Promise { + await sql`CREATE TABLE "file_vault" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "parentId" uuid, + "name" character varying NOT NULL, + "isFolder" boolean NOT NULL, + "size" bigint NOT NULL DEFAULT 0, + "checksum" character varying, + "deletedAt" timestamp with time zone, + "trashRootId" uuid, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + "updatedAt" timestamp with time zone NOT NULL DEFAULT now(), + "updateId" uuid NOT NULL DEFAULT immich_uuid_v7(), + CONSTRAINT "file_vault_ownerId_fkey" FOREIGN KEY ("ownerId") REFERENCES "user" ("id") ON UPDATE CASCADE ON DELETE CASCADE, + CONSTRAINT "file_vault_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "file_vault_ownerId_idx" ON "file_vault" ("ownerId");`.execute(db); + await sql`CREATE INDEX "file_vault_parentId_idx" ON "file_vault" ("parentId");`.execute(db); + await sql`CREATE INDEX "file_vault_trashRootId_idx" ON "file_vault" ("trashRootId");`.execute(db); + await sql`CREATE INDEX "file_vault_updateId_idx" ON "file_vault" ("updateId");`.execute(db); + await sql`CREATE OR REPLACE TRIGGER "file_vault_updatedAt" + BEFORE UPDATE ON "file_vault" + FOR EACH ROW + EXECUTE FUNCTION updated_at();`.execute(db); + await sql`ALTER TABLE "file_vault" ADD CONSTRAINT "file_vault_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "file_vault" ("id") ON UPDATE NO ACTION ON DELETE CASCADE;`.execute(db); + await sql`CREATE TABLE "file_vault_delete" ( + "id" uuid NOT NULL DEFAULT uuid_generate_v4(), + "ownerId" uuid NOT NULL, + "createdAt" timestamp with time zone NOT NULL DEFAULT now(), + CONSTRAINT "file_vault_delete_pkey" PRIMARY KEY ("id") +);`.execute(db); + await sql`CREATE INDEX "file_vault_delete_ownerId_idx" ON "file_vault_delete" ("ownerId");`.execute(db); + await sql`INSERT INTO "migration_overrides" ("name", "value") VALUES ('trigger_file_vault_updatedAt', '{"type":"trigger","name":"file_vault_updatedAt","sql":"CREATE OR REPLACE TRIGGER \\"file_vault_updatedAt\\"\\n BEFORE UPDATE ON \\"file_vault\\"\\n FOR EACH ROW\\n EXECUTE FUNCTION updated_at();"}'::jsonb);`.execute(db); +} + +export async function down(db: Kysely): Promise { + await sql`ALTER TABLE "file_vault" DROP CONSTRAINT "file_vault_parentId_fkey";`.execute(db); + await sql`DROP TRIGGER "file_vault_updatedAt" ON "file_vault";`.execute(db); + await sql`DROP TABLE "file_vault";`.execute(db); + await sql`DROP TABLE "file_vault_delete";`.execute(db); + await sql`DELETE FROM "migration_overrides" WHERE "name" = 'trigger_file_vault_updatedAt';`.execute(db); +} diff --git a/server/src/schema/migrations/ORDER b/server/src/schema/migrations/ORDER index 22ad10006..ae89d4438 100644 --- a/server/src/schema/migrations/ORDER +++ b/server/src/schema/migrations/ORDER @@ -102,3 +102,4 @@ 1792511451454-AddPasskeyTable 1792517020180-AddUpdateIdIndexes 1792568637987-AddAssetOwnerIdUpdateIdIndex +1793471628509-AddFileVault diff --git a/server/src/schema/tables/file-vault.table.ts b/server/src/schema/tables/file-vault.table.ts new file mode 100644 index 000000000..e013fa94a --- /dev/null +++ b/server/src/schema/tables/file-vault.table.ts @@ -0,0 +1,64 @@ +import { + Column, + CreateDateColumn, + ForeignKeyColumn, + type Generated, + PrimaryGeneratedColumn, + Table, + Timestamp, + UpdateDateColumn, +} from '@immich/sql-tools'; +import { UpdateIdColumn, UpdatedAtTrigger } from 'src/decorators.js'; +import { UserTable } from 'src/schema/tables/user.table.js'; + +@Table('file_vault') +@UpdatedAtTrigger('file_vault_updatedAt') +export class FileVaultTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @ForeignKeyColumn(() => UserTable, { onDelete: 'CASCADE', onUpdate: 'CASCADE' }) + ownerId!: string; + + @ForeignKeyColumn(() => FileVaultTable, { nullable: true, onDelete: 'CASCADE' }) + parentId!: string | null; + + @Column() + name!: string; + + @Column({ type: 'boolean' }) + isFolder!: boolean; + + @Column({ type: 'bigint', default: 0 }) + size!: Generated; + + @Column({ nullable: true }) + checksum!: string | null; + + @Column({ type: 'timestamp with time zone', nullable: true }) + deletedAt!: Timestamp | null; + + @Column({ type: 'uuid', nullable: true, index: true }) + trashRootId!: string | null; + + @CreateDateColumn() + createdAt!: Generated; + + @UpdateDateColumn() + updatedAt!: Generated; + + @UpdateIdColumn({ index: true }) + updateId!: Generated; +} + +@Table('file_vault_delete') +export class FileVaultDeleteTable { + @PrimaryGeneratedColumn() + id!: Generated; + + @Column({ type: 'uuid', index: true }) + ownerId!: string; + + @CreateDateColumn() + createdAt!: Generated; +} diff --git a/server/src/services/base.service.ts b/server/src/services/base.service.ts index 70d10854c..20b1a83ab 100644 --- a/server/src/services/base.service.ts +++ b/server/src/services/base.service.ts @@ -28,6 +28,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js'; import { EventRepository } from 'src/repositories/event.repository.js'; +import { FileVaultRepository } from 'src/repositories/file-vault.repository.js'; import { IntegrityRepository } from 'src/repositories/integrity.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js'; @@ -124,6 +125,7 @@ export const BASE_SERVICE_DEPENDENCIES = [ SharedLinkRepository, SharedLinkAssetRepository, StackRepository, + FileVaultRepository, StorageRepository, SyncRepository, SyncCheckpointRepository, @@ -190,6 +192,7 @@ export class BaseService { protected sharedLinkRepository: SharedLinkRepository, protected sharedLinkAssetRepository: SharedLinkAssetRepository, protected stackRepository: StackRepository, + protected fileVaultRepository: FileVaultRepository, protected storageRepository: StorageRepository, protected syncRepository: SyncRepository, protected syncCheckpointRepository: SyncCheckpointRepository, @@ -265,6 +268,7 @@ export class BaseService { ctx.sharedLinkRepository, ctx.sharedLinkAssetRepository, ctx.stackRepository, + ctx.fileVaultRepository, ctx.storageRepository, ctx.syncRepository, ctx.syncCheckpointRepository, diff --git a/server/src/services/file-vault.service.spec.ts b/server/src/services/file-vault.service.spec.ts new file mode 100644 index 000000000..36d4aed41 --- /dev/null +++ b/server/src/services/file-vault.service.spec.ts @@ -0,0 +1,44 @@ +import { + ConflictException, + ForbiddenException, + NotFoundException, + UnsupportedMediaTypeException, +} from '@nestjs/common'; +import { Readable } from 'node:stream'; +import { AuthDto } from 'src/dtos/auth.dto.js'; +import { FileVaultFailure } from 'src/repositories/file-vault.repository.js'; +import { FileVaultService } from 'src/services/file-vault.service.js'; +import { factory } from 'test/small.factory.js'; +import { newTestService } from 'test/utils.js'; + +describe(FileVaultService.name, () => { + it('rejects a parsed JSON body instead of storing an empty file', async () => { + const { sut, mocks } = newTestService(FileVaultService); + await expect( + sut.upload(factory.auth(), { name: 'report.json' }, Readable.from('{}'), 'application/json'), + ).rejects.toBeInstanceOf(UnsupportedMediaTypeException); + expect(mocks.fileVault.upload).not.toHaveBeenCalled(); + }); + it.each(['apiKey', 'sharedLink'] as const)('rejects %s before accepting uploaded bytes', async (kind) => { + const { sut, mocks } = newTestService(FileVaultService); + const auth = { ...factory.auth(), [kind]: {} } as AuthDto; + await expect(sut.upload(auth, { name: 'private.pdf' }, Readable.from('private'))).rejects.toBeInstanceOf( + ForbiddenException, + ); + expect(mocks.fileVault.upload).not.toHaveBeenCalled(); + }); + + it('does not expose another account through lookup failures', async () => { + const { sut, mocks } = newTestService(FileVaultService); + const auth = factory.auth(); + mocks.fileVault.download.mockRejectedValue(new FileVaultFailure('missing', 'File not found')); + await expect(sut.download(auth, factory.uuid())).rejects.toBeInstanceOf(NotFoundException); + expect(mocks.fileVault.download).toHaveBeenCalledWith(auth.user.id, expect.any(String)); + }); + + it('returns a conflict without overwriting a colliding name', async () => { + const { sut, mocks } = newTestService(FileVaultService); + mocks.fileVault.restore.mockRejectedValue(new FileVaultFailure('conflict', 'Name already exists')); + await expect(sut.restore(factory.auth(), factory.uuid())).rejects.toBeInstanceOf(ConflictException); + }); +}); diff --git a/server/src/services/file-vault.service.ts b/server/src/services/file-vault.service.ts new file mode 100644 index 000000000..905123aa1 --- /dev/null +++ b/server/src/services/file-vault.service.ts @@ -0,0 +1,156 @@ +import { + BadRequestException, + ConflictException, + ForbiddenException, + HttpException, + HttpStatus, + Injectable, + NotFoundException, + StreamableFile, + UnsupportedMediaTypeException, +} from '@nestjs/common'; +import { Readable } from 'node:stream'; +import { OnEvent } from 'src/decorators.js'; +import { AuthDto } from 'src/dtos/auth.dto.js'; +import { + FileVaultCreateFolderDto, + FileVaultEntryDto, + FileVaultFolderDto, + FileVaultListDto, + FileVaultListResponseDto, + FileVaultUpdateDto, + FileVaultUploadDto, +} from 'src/dtos/file-vault.dto.js'; +import { ImmichWorker, Permission } from 'src/enum.js'; +import { FileVaultFailure, VaultEntry } from 'src/repositories/file-vault.repository.js'; +import { BaseService } from 'src/services/base.service.js'; +import { handlePromiseError } from 'src/utils/misc.js'; + +const mapEntry = (entry: VaultEntry): FileVaultEntryDto => ({ + id: entry.id, + parentId: entry.parentId, + name: entry.name, + isFolder: entry.isFolder, + size: Number(entry.size), + checksum: entry.checksum, + createdAt: new Date(entry.createdAt).toISOString(), + updatedAt: new Date(entry.updatedAt).toISOString(), + deletedAt: entry.deletedAt ? new Date(entry.deletedAt).toISOString() : null, +}); +const mapFolder = (entry: VaultEntry): FileVaultFolderDto => ({ + id: entry.id, + name: entry.name, + parentId: entry.parentId, +}); + +@Injectable() +export class FileVaultService extends BaseService { + @OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] }) + async onBootstrap() { + await this.flushDeletes(); + this.cronRepository.create({ + name: 'file-vault-cleanup', + expression: '17 * * * *', + onTick: () => handlePromiseError(this.flushDeletes(), this.logger), + }); + } + + private async flushDeletes() { + const failed = await this.fileVaultRepository.flushDeletes(); + if (failed.length > 0) { + this.logger.warn(`File storage cleanup will retry ${failed.length} deleted files`); + } + } + + @OnEvent({ name: 'UserDelete' }) + handleUserDelete(user: { id: string }) { + return this.fileVaultRepository.removeUserFiles(user.id); + } + + private async run(auth: AuthDto, handler: () => Promise) { + if (auth.apiKey || auth.sharedLink) { + throw new ForbiddenException('Personal file storage requires a user session'); + } + await this.requireAccess({ auth, permission: Permission.FileVaultRead, ids: [auth.user.id] }); + try { + return await handler(); + } catch (error) { + if (!(error instanceof FileVaultFailure)) { + throw error; + } + if (error.code === 'missing') { + throw new NotFoundException(error.message); + } + if (error.code === 'conflict') { + throw new ConflictException(error.message); + } + if (error.code === 'quota' || error.code === 'space') { + throw new HttpException(error.message, HttpStatus.INSUFFICIENT_STORAGE); + } + throw new BadRequestException(error.message); + } + } + + list(auth: AuthDto, dto: FileVaultListDto): Promise { + return this.run(auth, async () => { + const result = await this.fileVaultRepository.list(auth.user.id, dto); + return { + ...result, + entries: result.entries.map((entry) => mapEntry(entry)), + breadcrumbs: result.breadcrumbs.map((entry) => mapFolder(entry)), + }; + }); + } + + folders(auth: AuthDto): Promise { + return this.run(auth, async () => { + const folders = await this.fileVaultRepository.folders(auth.user.id); + return folders.map((entry) => mapFolder(entry)); + }); + } + createFolder(auth: AuthDto, dto: FileVaultCreateFolderDto): Promise { + return this.run(auth, async () => + mapEntry(await this.fileVaultRepository.createFolder(auth.user.id, dto.name, dto.parentId ?? null)), + ); + } + upload( + auth: AuthDto, + dto: FileVaultUploadDto, + stream: Readable, + contentType = 'application/octet-stream', + ): Promise { + return this.run(auth, async () => { + if (contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/octet-stream') { + throw new UnsupportedMediaTypeException('Upload raw file bytes as application/octet-stream'); + } + return mapEntry(await this.fileVaultRepository.upload(auth.user.id, dto.name, dto.parentId ?? null, stream)); + }); + } + update(auth: AuthDto, id: string, dto: FileVaultUpdateDto): Promise { + return this.run(auth, async () => mapEntry(await this.fileVaultRepository.update(auth.user.id, id, dto))); + } + trash(auth: AuthDto, id: string) { + return this.run(auth, () => this.fileVaultRepository.trash(auth.user.id, id)); + } + restore(auth: AuthDto, id: string) { + return this.run(auth, () => this.fileVaultRepository.restore(auth.user.id, id)); + } + purge(auth: AuthDto, id: string) { + return this.run(auth, async () => { + const failed = await this.fileVaultRepository.purge(auth.user.id, id); + if (failed.length > 0) { + this.logger.warn(`File storage cleanup will retry ${failed.length} deleted files`); + } + }); + } + download(auth: AuthDto, id: string): Promise { + return this.run(auth, async () => { + const { entry, stream } = await this.fileVaultRepository.download(auth.user.id, id); + return new StreamableFile(stream, { + type: entry.isFolder ? 'application/zip' : 'application/octet-stream', + length: entry.isFolder ? undefined : Number(entry.size), + disposition: `attachment; filename*=UTF-8''${encodeURIComponent(entry.name + (entry.isFolder ? '.zip' : ''))}`, + }); + }); + } +} diff --git a/server/src/services/futo-backups.service.ts b/server/src/services/futo-backups.service.ts index 1d2d551d3..50944f07e 100644 --- a/server/src/services/futo-backups.service.ts +++ b/server/src/services/futo-backups.service.ts @@ -32,7 +32,7 @@ export class FutoBackupsService { this.orchestratorService.setImmichIntegration({ dataPath: StorageCore.getMediaLocation(), - dataFolders: Object.values(StorageFolder), + dataFolders: [...Object.values(StorageFolder), 'files'], libraries: libraries .filter((library) => !library.deletedAt) .map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })), diff --git a/server/src/services/index.ts b/server/src/services/index.ts index 0599fa4c5..ef90dd941 100644 --- a/server/src/services/index.ts +++ b/server/src/services/index.ts @@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js'; import { DatabaseService } from 'src/services/database.service.js'; import { DownloadService } from 'src/services/download.service.js'; import { DuplicateService } from 'src/services/duplicate.service.js'; +import { FileVaultService } from 'src/services/file-vault.service.js'; import { FutoBackupsService } from 'src/services/futo-backups.service.js'; import { HlsService } from 'src/services/hls.service.js'; import { IntegrityService } from 'src/services/integrity.service.js'; @@ -55,6 +56,7 @@ import { WorkflowExecutionService } from 'src/services/workflow-execution.servic import { WorkflowService } from 'src/services/workflow.service.js'; export const services = [ + FileVaultService, ApiKeyService, ActivityService, AlbumService, diff --git a/server/src/utils/access.ts b/server/src/utils/access.ts index 271023c3b..1244edfbe 100644 --- a/server/src/utils/access.ts +++ b/server/src/utils/access.ts @@ -152,6 +152,9 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe const { auth, permission, ids } = request; switch (permission) { + case Permission.FileVaultRead: { + return new Set([...ids].filter((id) => id === auth.user.id)); + } // uses album id case Permission.ActivityCreate: { return await access.activity.checkCreateAccess(auth.user.id, ids); diff --git a/server/test/medium.factory.ts b/server/test/medium.factory.ts index 9dcde56e8..705a89986 100644 --- a/server/test/medium.factory.ts +++ b/server/test/medium.factory.ts @@ -39,6 +39,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js'; import { EventRepository } from 'src/repositories/event.repository.js'; +import { FileVaultRepository } from 'src/repositories/file-vault.repository.js'; import { IntegrityRepository } from 'src/repositories/integrity.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js'; @@ -529,6 +530,7 @@ const newRealRepository = (key: T, db: Kysely case NotificationRepository: case OcrRepository: case AuthChallengeRepository: + case FileVaultRepository: case PartnerRepository: case PasskeyRepository: case PersonRepository: @@ -603,6 +605,7 @@ const newMockRepository = (key: ClassConstructor) => { case IntegrityRepository: case NotificationRepository: case OcrRepository: + case FileVaultRepository: case PartnerRepository: case PasskeyRepository: case PersonRepository: diff --git a/server/test/medium/specs/repositories/file-vault.repository.spec.ts b/server/test/medium/specs/repositories/file-vault.repository.spec.ts new file mode 100644 index 000000000..619786c9f --- /dev/null +++ b/server/test/medium/specs/repositories/file-vault.repository.spec.ts @@ -0,0 +1,236 @@ +import { type Kysely } from 'kysely'; +import { createHash, randomUUID } from 'node:crypto'; +import { mkdir, mkdtemp, readFile, readdir, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { Readable } from 'node:stream'; +import { StorageCore } from 'src/cores/storage.core.js'; +import { FileVaultRepository } from 'src/repositories/file-vault.repository.js'; +import { LoggingRepository } from 'src/repositories/logging.repository.js'; +import { UserRepository } from 'src/repositories/user.repository.js'; +import { DB } from 'src/schema/index.js'; +import { BaseService } from 'src/services/base.service.js'; +import { newMediumService } from 'test/medium.factory.js'; +import { getKyselyDB } from 'test/utils.js'; + +describe(FileVaultRepository.name, () => { + let db: Kysely; + let repository: FileVaultRepository; + let root: string; + let owner: string; + let other: string; + + beforeAll(async () => { + db = await getKyselyDB(); + repository = new FileVaultRepository(db); + }); + beforeEach(async () => { + root = await mkdtemp(join(tmpdir(), 'immich-files-')); + StorageCore.setMediaLocation(root); + const { ctx } = newMediumService(BaseService, { database: db, real: [], mock: [LoggingRepository] }); + const first = await ctx.newUser(); + const second = await ctx.newUser(); + owner = first.user.id; + other = second.user.id; + }); + afterEach(async () => { + await rm(root, { recursive: true, force: true }); + }); + const list = (id = owner, parentId?: string, trash = false) => + repository.list(id, { parentId, trash: trash ? 'true' : 'false', offset: 0 }); + const upload = (name: string, parentId: string | null = null, content = Buffer.from('private\0document\u{FF}')) => + repository.upload(owner, name, parentId, Readable.from(content)); + const quota = async () => { + const user = await db + .selectFrom('user') + .select('quotaUsageInBytes') + .where('id', '=', owner) + .executeTakeFirstOrThrow(); + return Number(user.quotaUsageInBytes); + }; + + it('preserves arbitrary and empty original bytes with SHA-256', async () => { + const content = Buffer.from([0, 1, 255, 13, 10, 0, 200]); + const file = await upload('Рабочий проект.dwg', null, content); + expect(file.checksum).toBe(createHash('sha256').update(content).digest('hex')); + expect(await readFile(join(root, 'files', owner, file.id))).toEqual(content); + const downloaded = await repository.download(owner, file.id); + expect(await downloaded.stream.toArray()).toEqual([content]); + const empty = await upload('empty.zip', null, Buffer.alloc(0)); + expect(Number(empty.size)).toBe(0); + }); + + it('isolates every folder and file operation by owner', async () => { + const folder = await repository.createFolder(owner, 'Work', null); + const file = await upload('plan.pdf', folder.id); + await expect(list(other)).resolves.toMatchObject({ entries: [] }); + for (const action of [ + () => repository.download(other, file.id), + () => repository.update(other, file.id, { name: 'stolen' }), + () => repository.trash(other, folder.id), + () => repository.restore(other, folder.id), + () => repository.purge(other, folder.id), + () => repository.createFolder(other, 'Nested', folder.id), + ]) { + await expect(action()).rejects.toMatchObject({ code: 'missing' }); + } + }); + + it('streams folder downloads as ZIP including empty directories and excluding trashed contents', async () => { + const folder = await repository.createFolder(owner, 'Work', null); + await repository.createFolder(owner, 'Empty', folder.id); + const content = Buffer.from('unmodified work document'); + await upload('report.pdf', folder.id, content); + const trashed = await upload('hidden.zip', folder.id, Buffer.from('excluded private content')); + await repository.trash(owner, trashed.id); + const download = await repository.download(owner, folder.id); + const archive = Buffer.concat(await download.stream.toArray()); + expect(archive.subarray(0, 2).toString()).toBe('PK'); + expect(archive.includes(Buffer.from('Work/Empty/'))).toBe(true); + expect(archive.includes(Buffer.from('Work/report.pdf'))).toBe(true); + expect(archive.includes(content)).toBe(true); + expect(archive.includes(Buffer.from('hidden.zip'))).toBe(false); + }); + + it('keeps file data unchanged when renaming or moving a folder tree', async () => { + const folder = await repository.createFolder(owner, 'Project', null); + const nested = await repository.createFolder(owner, 'Drawings', folder.id); + const target = await repository.createFolder(owner, 'Archive', null); + const file = await upload('part.step', nested.id); + await repository.update(owner, folder.id, { name: 'Project 2', parentId: target.id }); + const listing = await list(owner, nested.id); + expect(listing.breadcrumbs.map((row) => row.name)).toEqual(['Archive', 'Project 2', 'Drawings']); + expect(listing.entries[0].id).toBe(file.id); + const downloaded = await repository.download(owner, file.id); + expect(downloaded.entry.checksum).toBe(file.checksum); + }); + + it('rejects recursive folder moves and unavailable destinations', async () => { + const folder = await repository.createFolder(owner, 'Project', null); + const nested = await repository.createFolder(owner, 'Nested', folder.id); + await expect(repository.update(owner, folder.id, { parentId: nested.id })).rejects.toMatchObject({ + code: 'invalid', + }); + await expect(repository.update(owner, folder.id, { parentId: folder.id.toUpperCase() })).rejects.toMatchObject({ + code: 'invalid', + }); + await repository.trash(owner, nested.id); + await expect(upload('plan.pdf', nested.id)).rejects.toMatchObject({ code: 'missing' }); + }); + + it('serializes simultaneous duplicate uploads without overwriting bytes', async () => { + const results = await Promise.allSettled([upload('Report.pdf'), upload('report.pdf')]); + expect(results.filter((result) => result.status === 'fulfilled')).toHaveLength(1); + await expect(list()).resolves.toHaveProperty('entries.length', 1); + expect(await readdir(join(root, 'files', owner))).toHaveLength(1); + }); + + it('restores a folder without restoring items that were trashed earlier', async () => { + const folder = await repository.createFolder(owner, 'Work', null); + const keep = await upload('keep.pdf', folder.id); + const previous = await upload('previous.pdf', folder.id); + await repository.trash(owner, previous.id); + await repository.trash(owner, folder.id); + await expect(repository.download(owner, keep.id)).rejects.toMatchObject({ code: 'missing' }); + await repository.restore(owner, folder.id); + await expect(list(owner, folder.id)).resolves.toMatchObject({ entries: [{ id: keep.id }] }); + await expect(list(owner, undefined, true)).resolves.toMatchObject({ entries: [{ id: previous.id }] }); + }); + + it('restores to root when the original parent is unavailable', async () => { + const folder = await repository.createFolder(owner, 'Work', null); + const file = await upload('report.pdf', folder.id); + await repository.trash(owner, file.id); + await repository.trash(owner, folder.id); + await repository.restore(owner, file.id); + await expect(list()).resolves.toMatchObject({ entries: [{ id: file.id }] }); + }); + + it('requires resolving a restore name conflict and retains the original file', async () => { + const file = await upload('report.pdf'); + await repository.trash(owner, file.id); + await upload('report.pdf'); + await expect(repository.restore(owner, file.id)).rejects.toMatchObject({ code: 'conflict' }); + await repository.update(owner, file.id, { name: 'report restored.pdf' }); + await repository.restore(owner, file.id); + await expect(list()).resolves.toHaveProperty('entries.length', 2); + }); + + it('permanently deletes nested data and releases quota only after purge', async () => { + const folder = await repository.createFolder(owner, 'Project', null); + const file = await upload('plan.pdf', folder.id, Buffer.alloc(123)); + await expect(repository.purge(owner, folder.id)).rejects.toMatchObject({ code: 'invalid' }); + await repository.trash(owner, folder.id); + await expect(list()).resolves.toHaveProperty('usedBytes', 123); + await repository.purge(owner, folder.id); + await expect(list(owner, undefined, true)).resolves.toMatchObject({ entries: [] }); + await expect(readFile(join(root, 'files', owner, file.id))).rejects.toMatchObject({ code: 'ENOENT' }); + await expect(quota()).resolves.toBe(0); + }); + + it('persists failed physical cleanup and retries it after deletion is committed', async () => { + const file = await upload('plan.pdf'); + const path = join(root, 'files', owner, file.id); + await rm(path); + await mkdir(path); + await repository.trash(owner, file.id); + expect(await repository.purge(owner, file.id)).toEqual([file.id]); + await expect(list(owner, undefined, true)).resolves.toMatchObject({ entries: [] }); + expect( + await db.selectFrom('file_vault_delete').select('id').where('id', '=', file.id).executeTakeFirst(), + ).toBeDefined(); + await rm(path, { recursive: true }); + expect(await repository.flushDeletes(owner)).toEqual([]); + expect( + await db.selectFrom('file_vault_delete').select('id').where('id', '=', file.id).executeTakeFirst(), + ).toBeUndefined(); + }); + + it('cleans incomplete uploads without publishing catalog entries', async () => { + const input = Readable.from( + (function* () { + yield Buffer.from('partial'); + throw new Error('connection interrupted'); + })(), + ); + await expect(repository.upload(owner, 'interrupted.zip', null, input)).rejects.toThrow('connection interrupted'); + await expect(list()).resolves.toMatchObject({ entries: [] }); + expect(await readdir(join(root, 'files', owner))).toEqual([]); + }); + + it('enforces quota and includes files when recalculating account usage', async () => { + await db.updateTable('user').set({ quotaSizeInBytes: 10 }).where('id', '=', owner).execute(); + await expect(upload('large.zip', null, Buffer.alloc(11))).rejects.toMatchObject({ code: 'quota' }); + await upload('small.zip', null, Buffer.alloc(8)); + const { ctx } = newMediumService(BaseService, { database: db, real: [], mock: [LoggingRepository] }); + await ctx.get(UserRepository).syncUsage(owner); + await expect(quota()).resolves.toBe(8); + await expect(upload('another.zip', null, Buffer.alloc(3))).rejects.toMatchObject({ code: 'quota' }); + }); + + it('does not create or move folders beyond the supported nesting depth', async () => { + const ids = Array.from({ length: 127 }, () => randomUUID()); + await db + .insertInto('file_vault') + .values( + ids.map((id, index) => ({ + id, + ownerId: owner, + parentId: index === 0 ? null : ids[index - 1], + name: `Level ${index}`, + isFolder: true, + checksum: null, + deletedAt: null, + trashRootId: null, + })), + ) + .execute(); + const boundary = await repository.createFolder(owner, 'Boundary', ids.at(-1)!); + await expect(repository.createFolder(owner, 'Too deep', boundary.id)).rejects.toMatchObject({ code: 'invalid' }); + const folder = await repository.createFolder(owner, 'Root project', null); + await repository.createFolder(owner, 'Child', folder.id); + await expect(repository.update(owner, folder.id, { parentId: ids.at(-1)! })).rejects.toMatchObject({ + code: 'invalid', + }); + }); +}); diff --git a/server/test/utils.ts b/server/test/utils.ts index 84bb45831..209f6ddf2 100644 --- a/server/test/utils.ts +++ b/server/test/utils.ts @@ -39,6 +39,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js'; import { EventRepository } from 'src/repositories/event.repository.js'; +import { FileVaultRepository } from 'src/repositories/file-vault.repository.js'; import { IntegrityRepository } from 'src/repositories/integrity.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js'; @@ -284,6 +285,7 @@ export type ServiceOverrides = { sharedLink: SharedLinkRepository; sharedLinkAsset: SharedLinkAssetRepository; stack: StackRepository; + fileVault: FileVaultRepository; storage: StorageRepository; sync: SyncRepository; syncCheckpoint: SyncCheckpointRepository; @@ -375,6 +377,7 @@ export const getMocks = () => { sharedLink: automock(SharedLinkRepository), sharedLinkAsset: automock(SharedLinkAssetRepository), stack: automock(StackRepository), + fileVault: automock(FileVaultRepository, { strict: true }), storage: newStorageRepositoryMock(), sync: automock(SyncRepository), syncCheckpoint: automock(SyncCheckpointRepository), @@ -452,6 +455,7 @@ export const newTestService = ( overrides.sharedLink || (mocks.sharedLink as As), overrides.sharedLinkAsset || (mocks.sharedLinkAsset as As), overrides.stack || (mocks.stack as As), + overrides.fileVault || (mocks.fileVault as As), overrides.storage || (mocks.storage as As), overrides.sync || (mocks.sync as As), overrides.syncCheckpoint || (mocks.syncCheckpoint as As), diff --git a/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte b/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte index de5e7a20b..c8d66aa01 100644 --- a/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte +++ b/web/src/lib/components/shared-components/side-bar/UserSidebar.svelte @@ -1,4 +1,6 @@ + + +
+
+ {labels('root')} + {$t('trash')} + {#if !trash} + + + + {/if} + uploadFiles([...(event.currentTarget.files ?? [])])} + aria-label={labels('upload_files')} + /> + uploadFiles([...(event.currentTarget.files ?? [])])} + aria-label={labels('upload_folder')} + /> +
+ {#if !trash} + + {/if} +

+ {labels('private')} + {labels('used')}: {size(query.data.usedBytes)} · {labels('limit')}: {size(query.data.maxFileBytes)} +

+ {#if error}{/if} + {#if busy && progress}
+ {labels('uploading')}: {progress} +
{/if} +
+ + + + {#each entries as entry (entry.id)} + + + + + + {/each} + {#if entries.length === 0}{/if} + +
{$t('name')}{$t('file_size')}{$t('select')}
+ {#if entry.isFolder}{:else}{/if} + {#if entry.isFolder && !trash}{entry.name}{:else}{entry.name}{/if} + {entry.isFolder ? '—' : size(entry.size)}
+ {#if trash} + + + + {:else} + {$t('download')} + + + + {/if} +
{labels(trash ? 'empty_trash' : 'empty')}
+
+
+ {#if offset > 0}{/if} + {#if query.data.hasMore}{/if} +
+
+
+ + (modal = null)} + class="w-[min(92vw,30rem)] rounded-2xl bg-white p-6 text-gray-900 shadow-2xl backdrop:bg-black/50 dark:bg-gray-900 dark:text-gray-100" + aria-labelledby="vault-dialog-title" +> +
+

+ {modal === 'create' + ? labels('new_folder') + : modal === 'move' + ? labels('move_to') + : modal === 'rename' + ? $t('rename') + : modal === 'purge' + ? $t('delete_permanently') + : $t('trash')} +

+ {#if modal === 'create' || modal === 'rename'}{/if} + {#if modal === 'move'}{/if} + {#if modal === 'trash' || modal === 'purge'}

{labels(modal === 'trash' ? 'trash_confirm' : 'purge_confirm')}

+

{selected?.name}

{/if} + {#if error}{/if} +
+ +
+
+
+ + diff --git a/web/src/routes/(user)/files/+page.ts b/web/src/routes/(user)/files/+page.ts new file mode 100644 index 000000000..09b52ab36 --- /dev/null +++ b/web/src/routes/(user)/files/+page.ts @@ -0,0 +1,16 @@ +import { queryClient } from '#lib/queries/client.ts'; +import { getFileVaultQuery } from '#lib/queries/file-vault.query.ts'; +import { authenticate } from '#lib/utils/auth.ts'; +import { fileVaultLabel } from '#lib/utils/file-vault-i18n.ts'; +import { locale } from 'svelte-i18n'; +import { get } from 'svelte/store'; +import type { PageLoad } from './$types'; + +export const load = (async ({ url }) => { + await authenticate(url); + const parentId = url.searchParams.get('folder') ?? undefined; + const trash = url.searchParams.get('trash') === 'true'; + const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0); + await queryClient.query(getFileVaultQuery(parentId, trash, offset)); + return { meta: { title: fileVaultLabel(get(locale), 'title') } }; +}) satisfies PageLoad;