Author SHA1 Message Date
Codex 2800192c76 feat: add private file storage for web and Android 2026-10-31 23:07:09 +03:00
50 changed files with 3956 additions and 93 deletions
+25
View File
@@ -1175,6 +1175,31 @@
"file_name_or_extension": "File name or extension", "file_name_or_extension": "File name or extension",
"file_name_text": "File name", "file_name_text": "File name",
"file_size": "File size", "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", "filename": "Filename",
"filetype": "Filetype", "filetype": "Filetype",
"filter": "Filter", "filter": "Filter",
@@ -31,6 +31,11 @@
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false" android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
android:networkSecurityConfig="@xml/network_security_config"> android:networkSecurityConfig="@xml/network_security_config">
<provider android:name=".files.FileVaultProvider"
android:authorities="${applicationId}.files" android:exported="false" android:grantUriPermissions="true">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:resource="@xml/file_vault_paths" />
</provider>
<profileable android:shell="true" /> <profileable android:shell="true" />
<provider android:name="androidx.core.content.FileProvider" <provider android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.updates" android:exported="false" android:authorities="${applicationId}.updates" android:exported="false"
@@ -95,6 +100,17 @@
<data android:mimeType="video/*" /> <data android:mimeType="video/*" />
</intent-filter> </intent-filter>
<intent-filter android:label="Immich">
<action android:name="android.intent.action.SEND" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<intent-filter android:label="Immich">
<action android:name="android.intent.action.SEND_MULTIPLE" />
<category android:name="android.intent.category.DEFAULT" />
<data android:mimeType="*/*" />
</intent-filter>
<!-- Allow Immich to act as an image viewer --> <!-- Allow Immich to act as an image viewer -->
<intent-filter android:label="View in Immich"> <intent-filter android:label="View in Immich">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
@@ -1,81 +1,82 @@
package app.alextran.immich package app.alextran.immich
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.os.Build import android.os.Build
import android.os.ext.SdkExtensions import android.os.ext.SdkExtensions
import app.alextran.immich.background.BackgroundEngineLock import app.alextran.immich.background.BackgroundEngineLock
import app.alextran.immich.background.BackgroundWorkerApiImpl import app.alextran.immich.background.BackgroundWorkerApiImpl
import app.alextran.immich.background.BackgroundWorkerFgHostApi import app.alextran.immich.background.BackgroundWorkerFgHostApi
import app.alextran.immich.background.BackgroundWorkerLockApi import app.alextran.immich.background.BackgroundWorkerLockApi
import app.alextran.immich.connectivity.ConnectivityApi import app.alextran.immich.connectivity.ConnectivityApi
import app.alextran.immich.connectivity.ConnectivityApiImpl import app.alextran.immich.connectivity.ConnectivityApiImpl
import app.alextran.immich.core.HttpClientManager import app.alextran.immich.core.HttpClientManager
import app.alextran.immich.core.ImmichPlugin import app.alextran.immich.core.ImmichPlugin
import app.alextran.immich.core.NetworkApiPlugin import app.alextran.immich.core.NetworkApiPlugin
import me.albemala.native_video_player.NativeVideoPlayerPlugin import me.albemala.native_video_player.NativeVideoPlayerPlugin
import app.alextran.immich.images.LocalImageApi import app.alextran.immich.images.LocalImageApi
import app.alextran.immich.images.LocalImagesImpl import app.alextran.immich.images.LocalImagesImpl
import app.alextran.immich.images.RemoteImageApi import app.alextran.immich.images.RemoteImageApi
import app.alextran.immich.images.RemoteImagesImpl import app.alextran.immich.images.RemoteImagesImpl
import app.alextran.immich.permission.PermissionApi import app.alextran.immich.permission.PermissionApi
import app.alextran.immich.permission.PermissionApiImpl import app.alextran.immich.permission.PermissionApiImpl
import app.alextran.immich.sync.NativeSyncApi import app.alextran.immich.sync.NativeSyncApi
import app.alextran.immich.sync.NativeSyncApiImpl26 import app.alextran.immich.sync.NativeSyncApiImpl26
import app.alextran.immich.sync.NativeSyncApiImpl30 import app.alextran.immich.sync.NativeSyncApiImpl30
import app.alextran.immich.viewintent.ViewIntentPlugin import app.alextran.immich.viewintent.ViewIntentPlugin
import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.android.FlutterFragmentActivity
import io.flutter.embedding.engine.FlutterEngine import io.flutter.embedding.engine.FlutterEngine
class MainActivity : FlutterFragmentActivity() { class MainActivity : FlutterFragmentActivity() {
override fun configureFlutterEngine(flutterEngine: FlutterEngine) { override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
super.configureFlutterEngine(flutterEngine) super.configureFlutterEngine(flutterEngine)
AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger) AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger)
registerPlugins(this, flutterEngine) flutterEngine.plugins.add(app.alextran.immich.files.FileVaultPlugin())
} registerPlugins(this, flutterEngine)
}
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent) override fun onNewIntent(intent: Intent) {
setIntent(intent) super.onNewIntent(intent)
} setIntent(intent)
}
companion object {
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) { companion object {
HttpClientManager.initialize(ctx) fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory HttpClientManager.initialize(ctx)
flutterEngine.plugins.add(NetworkApiPlugin()) NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
flutterEngine.plugins.add(NetworkApiPlugin())
val messenger = flutterEngine.dartExecutor.binaryMessenger
val backgroundEngineLockImpl = BackgroundEngineLock(ctx) val messenger = flutterEngine.dartExecutor.binaryMessenger
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl) val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
val nativeSyncApiImpl = BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) { val nativeSyncApiImpl =
NativeSyncApiImpl26(ctx) if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
} else { NativeSyncApiImpl26(ctx)
NativeSyncApiImpl30(ctx) } else {
} NativeSyncApiImpl30(ctx)
val permissionApiImpl = PermissionApiImpl(ctx) }
NativeSyncApi.setUp(messenger, nativeSyncApiImpl) val permissionApiImpl = PermissionApiImpl(ctx)
PermissionApi.setUp(messenger, permissionApiImpl) NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx)) PermissionApi.setUp(messenger, permissionApiImpl)
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx)) LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx)) BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(backgroundEngineLockImpl) flutterEngine.plugins.add(ViewIntentPlugin())
flutterEngine.plugins.add(nativeSyncApiImpl) flutterEngine.plugins.add(backgroundEngineLockImpl)
flutterEngine.plugins.add(permissionApiImpl) flutterEngine.plugins.add(nativeSyncApiImpl)
} flutterEngine.plugins.add(permissionApiImpl)
}
fun cancelPlugins(flutterEngine: FlutterEngine) {
val nativeApi = fun cancelPlugins(flutterEngine: FlutterEngine) {
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin? val nativeApi =
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin? flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
nativeApi?.detachFromEngine() ?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin? nativeApi?.detachFromEngine()
permissionApi?.detachFromEngine() val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
} permissionApi?.detachFromEngine()
} }
} }
}
@@ -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<List<VaultPickedFile>>) -> Unit)? = null
private var pendingSave: ((Result<Boolean>) -> 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<File>()
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<List<VaultPickedFile>>) -> 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<Boolean>) -> 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
}
}
@@ -0,0 +1,5 @@
package app.alextran.immich.files
import androidx.core.content.FileProvider
class FileVaultProvider : FileProvider()
@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths xmlns:android="http://schemas.android.com/apk/res/android">
<cache-path name="file_vault_downloads" path="file-vault-downloads/" />
</paths>
+160
View File
@@ -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<Directory> Function()? temporaryDirectory, this.onChanged})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
final ApiService _apiService;
final FileVaultPlatform platform;
final Future<Directory> 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<VaultListing> 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<List<VaultFolder>> folders() async => (await _api.listFileFolders() ?? []).map(_folder).toList();
Future<void> createFolder(String name, String? parentId) async {
await _api.createFileFolder(FileVaultCreateFolderDto(name: name, parentId: Optional.present(parentId)));
onChanged?.call();
}
Future<void> rename(String id, String name) async {
await _api.updateFile(id, FileVaultUpdateDto(name: Optional.present(name)));
onChanged?.call();
}
Future<void> move(String id, String? parentId) async {
await _api.updateFile(id, FileVaultUpdateDto(parentId: Optional.present(parentId)));
onChanged?.call();
}
Future<void> trash(String id) async {
await _api.trashFile(id);
onChanged?.call();
}
Future<void> restore(String id) async {
await _api.restoreFile(id);
onChanged?.call();
}
Future<void> purge(String id) async {
await _api.permanentlyDeleteFile(id);
onChanged?.call();
}
Future<void> 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<File> 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<void>();
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;
}
}
}
@@ -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<VaultEntry> entries;
final List<VaultFolder> breadcrumbs;
final bool hasMore;
final int usedBytes;
final int maxFileBytes;
}
@@ -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<int>((_) => 0);
final fileVaultRepositoryProvider = Provider(
(ref) => FileVaultApiData(
ref.watch(apiServiceProvider),
FileVaultPlatform(),
onChanged: () => ref.read(fileVaultRevisionProvider.notifier).state++,
),
);
@@ -6,7 +6,7 @@ import 'package:path/path.dart';
part 'share_intent_attachment.model.freezed.dart'; part 'share_intent_attachment.model.freezed.dart';
enum ShareIntentAttachmentType { image, video } enum ShareIntentAttachmentType { image, video, file }
enum UploadStatus { enqueued, running, complete, failed } enum UploadStatus { enqueued, running, complete, failed }
+16 -1
View File
@@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:io';
import 'package:auto_route/auto_route.dart'; import 'package:auto_route/auto_route.dart';
import 'package:flutter/material.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/extensions/build_context_extensions.dart';
import 'package:immich_mobile/generated/translations.g.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/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/haptic_feedback.provider.dart';
import 'package:immich_mobile/providers/infrastructure/album.provider.dart'; import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart'; import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
@@ -55,6 +57,13 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor), selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
enabled: !isReadonlyModeEnabled, 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) { Widget navigationRail(TabsRouter tabsRouter) {
@@ -77,7 +86,13 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
} }
return AutoTabsRouter( 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), duration: const Duration(milliseconds: 600),
transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child), transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child),
builder: (context, child) { builder: (context, child) {
@@ -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<String> incomingPaths;
@override
ConsumerState<FileVaultPage> createState() => _FileVaultPageState();
}
class _FileVaultPageState extends ConsumerState<FileVaultPage> {
VaultListing? _listing;
String? _folderId;
bool _trash = false;
bool _busy = false;
int _offset = 0;
int _generation = 0;
String? _error;
String? _progress;
late List<String> _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<void> _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<void> _run(Future<void> 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<String?> _askName(String title, {String initial = ''}) async {
final controller = TextEditingController(text: initial);
try {
return await showDialog<String>(
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<bool> _confirm(VaultEntry entry, bool permanent) async =>
await showDialog<bool>(
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<void> _createFolder() async {
final name = await _askName(_labels.text('new_folder'));
if (name != null && mounted) {
await _run(() => ref.read(fileVaultRepositoryProvider).createFolder(name, _folderId));
}
}
Future<void> _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<void> _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<void> _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 = <String>{};
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<String>(
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<void> _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<void> _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 ?? <VaultFolder>[])
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<String>(
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),
],
),
),
),
],
),
);
}
}
@@ -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',
};
}
@@ -29,6 +29,11 @@ class ShareIntentUploadStateNotifier extends StateNotifier<List<ShareIntentAttac
} }
void onSharedMedia(List<ShareIntentAttachment> attachments) { void onSharedMedia(List<ShareIntentAttachment> 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"); router.removeWhere((route) => route.name == "ShareIntentRoute");
clearAttachments(); clearAttachments();
addAttachments(attachments); addAttachments(attachments);
+1 -1
View File
@@ -1,6 +1,6 @@
import 'package:hooks_riverpod/hooks_riverpod.dart'; 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 /// Provides the currently active tab
final tabProvider = StateProvider<TabEnum>((ref) => TabEnum.home); final tabProvider = StateProvider<TabEnum>((ref) => TabEnum.home);
@@ -36,7 +36,9 @@ class ShareHandlerRepository {
final type = attachment.type == SharedAttachmentType.image final type = attachment.type == SharedAttachmentType.image
? ShareIntentAttachmentType.image ? ShareIntentAttachmentType.image
: ShareIntentAttachmentType.video; : attachment.type == SharedAttachmentType.video
? ShareIntentAttachmentType.video
: ShareIntentAttachmentType.file;
final fileLength = File(attachment.path).lengthSync(); final fileLength = File(attachment.path).lengthSync();
+3
View File
@@ -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/edit/edit.page.dart';
import 'package:immich_mobile/presentation/pages/favorite.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/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/library.page.dart';
import 'package:immich_mobile/presentation/pages/local_album.page.dart'; import 'package:immich_mobile/presentation/pages/local_album.page.dart';
import 'package:immich_mobile/presentation/pages/local_timeline.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: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]), AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
AutoRoute(page: AlbumsRoute.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: ProfilePictureCropRoute.page),
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]), AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]), AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
+28
View File
@@ -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<VaultPickedFile> pickFiles();
@async
bool saveFile(String sourcePath, String suggestedName);
void openFile(String sourcePath, String suggestedName);
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone description: Immich - selfhosted backup media file on mobile phone
publish_to: 'none' publish_to: 'none'
version: 3.4.0-local.1+3040001 version: 3.4.0-local.2+3040002
environment: environment:
sdk: '>=3.12.0 <4.0.0' sdk: '>=3.12.0 <4.0.0'
+163
View File
@@ -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<http.Response> 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<String, Object?> entry({List<int> 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 = <Map<String, dynamic>>[];
final data = repository((request) async {
requests.add(jsonDecode(request.body) as Map<String, dynamic>);
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<ApiException>().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);
});
}
+629
View File
@@ -7229,6 +7229,486 @@
"x-immich-state": "Stable" "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": { "/futo-backups/auth/oidc/device": {
"get": { "get": {
"description": "Connect an existing FUTO Backups account to this server.", "description": "Connect an existing FUTO Backups account to this server.",
@@ -25965,6 +26445,154 @@
], ],
"type": "object" "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": { "FilesystemListingItemDto": {
"properties": { "properties": {
"isDirectory": { "isDirectory": {
@@ -29329,6 +29957,7 @@
"Permission": { "Permission": {
"description": "List of permissions", "description": "List of permissions",
"enum": [ "enum": [
"fileVault.read",
"all", "all",
"activity.create", "activity.create",
"activity.read", "activity.read",
+159
View File
@@ -1772,6 +1772,37 @@ export type FaceDto = {
/** Face ID */ /** Face ID */
id: string; 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 = { export type DeviceFlowEventDto = {
backendId?: string; backendId?: string;
reason?: DeviceFlowFailureReason; reason?: DeviceFlowFailureReason;
@@ -5986,6 +6017,133 @@ export function reassignFacesById({ id, faceDto }: {
body: 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 * Connect a FUTO Backups account
*/ */
@@ -9338,6 +9496,7 @@ export enum BulkIdErrorReason {
Validation = "validation" Validation = "validation"
} }
export enum Permission { export enum Permission {
FileVaultRead = "fileVault.read",
All = "all", All = "all",
ActivityCreate = "activity.create", ActivityCreate = "activity.create",
ActivityRead = "activity.read", ActivityRead = "activity.read",
@@ -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);
});
});
@@ -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<FileVaultListResponseDto> {
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<FileVaultFolderDto[]> {
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<FileVaultEntryDto> {
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<FileVaultEntryDto> {
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<FileVaultEntryDto> {
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);
}
}
+2
View File
@@ -15,6 +15,7 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
import { DownloadController } from 'src/controllers/download.controller.js'; import { DownloadController } from 'src/controllers/download.controller.js';
import { DuplicateController } from 'src/controllers/duplicate.controller.js'; import { DuplicateController } from 'src/controllers/duplicate.controller.js';
import { FaceController } from 'src/controllers/face.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 { IntegrityAdminController } from 'src/controllers/integrity-admin.controller.js';
import { JobController } from 'src/controllers/job.controller.js'; import { JobController } from 'src/controllers/job.controller.js';
import { LibraryController } from 'src/controllers/library.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'; import { WorkflowController } from 'src/controllers/workflow.controller.js';
export const controllers = [ export const controllers = [
FileVaultController,
ApiKeyController, ApiKeyController,
ActivityController, ActivityController,
AlbumController, AlbumController,
+69
View File
@@ -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' }),
) {}
+1
View File
@@ -131,6 +131,7 @@ export const AssetOrderWithRandomSchema = z
.meta({ id: 'MemorySearchOrder' }); .meta({ id: 'MemorySearchOrder' });
export enum Permission { export enum Permission {
FileVaultRead = 'fileVault.read',
All = 'all', All = 'all',
ActivityCreate = 'activity.create', ActivityCreate = 'activity.create',
+7
View File
@@ -399,6 +399,13 @@ set
where where
"asset"."libraryId" is null "asset"."libraryId" is null
and "asset"."ownerId" = "user"."id" and "asset"."ownerId" = "user"."id"
) + (
select
coalesce(sum("size"), 0) as "usage"
from
"file_vault"
where
"file_vault"."ownerId" = "user"."id"
), ),
"updatedAt" = $1 "updatedAt" = $1
where where
@@ -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<FileVaultTable>;
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<DB>) {}
private path(ownerId: string, id: string) {
return join(StorageCore.getMediaLocation(), 'files', ownerId, id);
}
private async locked<T>(ownerId: string, handler: (tx: Transaction<DB>) => Promise<T>) {
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<DB>, 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<DB>, ownerId: string, id: string | null, excluded?: string) {
const entries: VaultEntry[] = [];
const seen = new Set<string>();
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<DB>, 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<string>`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<number>('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<VaultEntry>`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<FileVaultTable> = {
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<VaultEntry & { path: string[] }>`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<VaultEntry>`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;
}
}
+2
View File
@@ -18,6 +18,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js';
import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js';
import { EmailRepository } from 'src/repositories/email.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js';
import { EventRepository } from 'src/repositories/event.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 { IntegrityRepository } from 'src/repositories/integrity.repository.js';
import { JobRepository } from 'src/repositories/job.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js';
@@ -104,6 +105,7 @@ export const repositories = [
SharedLinkRepository, SharedLinkRepository,
SharedLinkAssetRepository, SharedLinkAssetRepository,
StackRepository, StackRepository,
FileVaultRepository,
StorageRepository, StorageRepository,
SyncRepository, SyncRepository,
SyncCheckpointRepository, SyncCheckpointRepository,
+13 -6
View File
@@ -312,12 +312,19 @@ export class UserRepository {
.updateTable('user') .updateTable('user')
.set({ .set({
quotaUsageInBytes: (eb) => quotaUsageInBytes: (eb) =>
eb eb(
.selectFrom('asset') eb
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id') .selectFrom('asset')
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage')) .leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
.where('asset.libraryId', 'is', null) .select((eb) => eb.fn.coalesce(eb.fn.sum<number>('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage'))
.where('asset.ownerId', '=', eb.ref('user.id')), .where('asset.libraryId', 'is', null)
.where('asset.ownerId', '=', eb.ref('user.id')),
'+',
eb
.selectFrom('file_vault')
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>('size'), eb.lit(0)).as('usage'))
.where('file_vault.ownerId', '=', eb.ref('user.id')),
).$notNull(),
updatedAt: new Date(), updatedAt: new Date(),
}) })
.where('user.deletedAt', 'is', null) .where('user.deletedAt', 'is', null)
+5
View File
@@ -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 { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table.js';
import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js'; import { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js';
import { FaceSearchTable } from 'src/schema/tables/face-search.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 { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table.js';
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table.js'; import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table.js';
import { LibraryTable } from 'src/schema/tables/library.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' }) @Database({ name: 'immich' })
export class ImmichDatabase { export class ImmichDatabase {
tables = [ tables = [
FileVaultTable,
FileVaultDeleteTable,
ActivityTable, ActivityTable,
AlbumAssetTable, AlbumAssetTable,
AlbumAssetAuditTable, AlbumAssetAuditTable,
@@ -212,6 +215,8 @@ export interface Migrations {
} }
export interface DB { export interface DB {
file_vault: FileVaultTable;
file_vault_delete: FileVaultDeleteTable;
kysely_migrations: { timestamp: string; name: string }; kysely_migrations: { timestamp: string; name: string };
activity: ActivityTable; activity: ActivityTable;
@@ -0,0 +1,45 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
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<any>): Promise<void> {
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);
}
+1
View File
@@ -102,3 +102,4 @@
1792511451454-AddPasskeyTable 1792511451454-AddPasskeyTable
1792517020180-AddUpdateIdIndexes 1792517020180-AddUpdateIdIndexes
1792568637987-AddAssetOwnerIdUpdateIdIndex 1792568637987-AddAssetOwnerIdUpdateIdIndex
1793471628509-AddFileVault
@@ -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<string>;
@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<number>;
@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<Timestamp>;
@UpdateDateColumn()
updatedAt!: Generated<Timestamp>;
@UpdateIdColumn({ index: true })
updateId!: Generated<string>;
}
@Table('file_vault_delete')
export class FileVaultDeleteTable {
@PrimaryGeneratedColumn()
id!: Generated<string>;
@Column({ type: 'uuid', index: true })
ownerId!: string;
@CreateDateColumn()
createdAt!: Generated<Timestamp>;
}
+4
View File
@@ -28,6 +28,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js';
import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js';
import { EmailRepository } from 'src/repositories/email.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js';
import { EventRepository } from 'src/repositories/event.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 { IntegrityRepository } from 'src/repositories/integrity.repository.js';
import { JobRepository } from 'src/repositories/job.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js';
@@ -124,6 +125,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
SharedLinkRepository, SharedLinkRepository,
SharedLinkAssetRepository, SharedLinkAssetRepository,
StackRepository, StackRepository,
FileVaultRepository,
StorageRepository, StorageRepository,
SyncRepository, SyncRepository,
SyncCheckpointRepository, SyncCheckpointRepository,
@@ -190,6 +192,7 @@ export class BaseService {
protected sharedLinkRepository: SharedLinkRepository, protected sharedLinkRepository: SharedLinkRepository,
protected sharedLinkAssetRepository: SharedLinkAssetRepository, protected sharedLinkAssetRepository: SharedLinkAssetRepository,
protected stackRepository: StackRepository, protected stackRepository: StackRepository,
protected fileVaultRepository: FileVaultRepository,
protected storageRepository: StorageRepository, protected storageRepository: StorageRepository,
protected syncRepository: SyncRepository, protected syncRepository: SyncRepository,
protected syncCheckpointRepository: SyncCheckpointRepository, protected syncCheckpointRepository: SyncCheckpointRepository,
@@ -265,6 +268,7 @@ export class BaseService {
ctx.sharedLinkRepository, ctx.sharedLinkRepository,
ctx.sharedLinkAssetRepository, ctx.sharedLinkAssetRepository,
ctx.stackRepository, ctx.stackRepository,
ctx.fileVaultRepository,
ctx.storageRepository, ctx.storageRepository,
ctx.syncRepository, ctx.syncRepository,
ctx.syncCheckpointRepository, ctx.syncCheckpointRepository,
@@ -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);
});
});
+156
View File
@@ -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<T>(auth: AuthDto, handler: () => Promise<T>) {
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<FileVaultListResponseDto> {
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<FileVaultFolderDto[]> {
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<FileVaultEntryDto> {
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<FileVaultEntryDto> {
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<FileVaultEntryDto> {
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<StreamableFile> {
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' : ''))}`,
});
});
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export class FutoBackupsService {
this.orchestratorService.setImmichIntegration({ this.orchestratorService.setImmichIntegration({
dataPath: StorageCore.getMediaLocation(), dataPath: StorageCore.getMediaLocation(),
dataFolders: Object.values(StorageFolder), dataFolders: [...Object.values(StorageFolder), 'files'],
libraries: libraries libraries: libraries
.filter((library) => !library.deletedAt) .filter((library) => !library.deletedAt)
.map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })), .map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })),
+2
View File
@@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js';
import { DatabaseService } from 'src/services/database.service.js'; import { DatabaseService } from 'src/services/database.service.js';
import { DownloadService } from 'src/services/download.service.js'; import { DownloadService } from 'src/services/download.service.js';
import { DuplicateService } from 'src/services/duplicate.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 { FutoBackupsService } from 'src/services/futo-backups.service.js';
import { HlsService } from 'src/services/hls.service.js'; import { HlsService } from 'src/services/hls.service.js';
import { IntegrityService } from 'src/services/integrity.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'; import { WorkflowService } from 'src/services/workflow.service.js';
export const services = [ export const services = [
FileVaultService,
ApiKeyService, ApiKeyService,
ActivityService, ActivityService,
AlbumService, AlbumService,
+3
View File
@@ -152,6 +152,9 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
const { auth, permission, ids } = request; const { auth, permission, ids } = request;
switch (permission) { switch (permission) {
case Permission.FileVaultRead: {
return new Set([...ids].filter((id) => id === auth.user.id));
}
// uses album id // uses album id
case Permission.ActivityCreate: { case Permission.ActivityCreate: {
return await access.activity.checkCreateAccess(auth.user.id, ids); return await access.activity.checkCreateAccess(auth.user.id, ids);
+3
View File
@@ -39,6 +39,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository.js';
import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js';
import { EmailRepository } from 'src/repositories/email.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js';
import { EventRepository } from 'src/repositories/event.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 { IntegrityRepository } from 'src/repositories/integrity.repository.js';
import { JobRepository } from 'src/repositories/job.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js';
@@ -529,6 +530,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
case NotificationRepository: case NotificationRepository:
case OcrRepository: case OcrRepository:
case AuthChallengeRepository: case AuthChallengeRepository:
case FileVaultRepository:
case PartnerRepository: case PartnerRepository:
case PasskeyRepository: case PasskeyRepository:
case PersonRepository: case PersonRepository:
@@ -603,6 +605,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
case IntegrityRepository: case IntegrityRepository:
case NotificationRepository: case NotificationRepository:
case OcrRepository: case OcrRepository:
case FileVaultRepository:
case PartnerRepository: case PartnerRepository:
case PasskeyRepository: case PasskeyRepository:
case PersonRepository: case PersonRepository:
@@ -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<DB>;
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',
});
});
});
+4
View File
@@ -39,6 +39,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js';
import { DuplicateRepository } from 'src/repositories/duplicate.repository.js'; import { DuplicateRepository } from 'src/repositories/duplicate.repository.js';
import { EmailRepository } from 'src/repositories/email.repository.js'; import { EmailRepository } from 'src/repositories/email.repository.js';
import { EventRepository } from 'src/repositories/event.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 { IntegrityRepository } from 'src/repositories/integrity.repository.js';
import { JobRepository } from 'src/repositories/job.repository.js'; import { JobRepository } from 'src/repositories/job.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js'; import { LibraryRepository } from 'src/repositories/library.repository.js';
@@ -284,6 +285,7 @@ export type ServiceOverrides = {
sharedLink: SharedLinkRepository; sharedLink: SharedLinkRepository;
sharedLinkAsset: SharedLinkAssetRepository; sharedLinkAsset: SharedLinkAssetRepository;
stack: StackRepository; stack: StackRepository;
fileVault: FileVaultRepository;
storage: StorageRepository; storage: StorageRepository;
sync: SyncRepository; sync: SyncRepository;
syncCheckpoint: SyncCheckpointRepository; syncCheckpoint: SyncCheckpointRepository;
@@ -375,6 +377,7 @@ export const getMocks = () => {
sharedLink: automock(SharedLinkRepository), sharedLink: automock(SharedLinkRepository),
sharedLinkAsset: automock(SharedLinkAssetRepository), sharedLinkAsset: automock(SharedLinkAssetRepository),
stack: automock(StackRepository), stack: automock(StackRepository),
fileVault: automock(FileVaultRepository, { strict: true }),
storage: newStorageRepositoryMock(), storage: newStorageRepositoryMock(),
sync: automock(SyncRepository), sync: automock(SyncRepository),
syncCheckpoint: automock(SyncCheckpointRepository), syncCheckpoint: automock(SyncCheckpointRepository),
@@ -452,6 +455,7 @@ export const newTestService = <T extends BaseService>(
overrides.sharedLink || (mocks.sharedLink as As<SharedLinkRepository>), overrides.sharedLink || (mocks.sharedLink as As<SharedLinkRepository>),
overrides.sharedLinkAsset || (mocks.sharedLinkAsset as As<SharedLinkAssetRepository>), overrides.sharedLinkAsset || (mocks.sharedLinkAsset as As<SharedLinkAssetRepository>),
overrides.stack || (mocks.stack as As<StackRepository>), overrides.stack || (mocks.stack as As<StackRepository>),
overrides.fileVault || (mocks.fileVault as As<FileVaultRepository>),
overrides.storage || (mocks.storage as As<StorageRepository>), overrides.storage || (mocks.storage as As<StorageRepository>),
overrides.sync || (mocks.sync as As<SyncRepository>), overrides.sync || (mocks.sync as As<SyncRepository>),
overrides.syncCheckpoint || (mocks.syncCheckpoint as As<SyncCheckpointRepository>), overrides.syncCheckpoint || (mocks.syncCheckpoint as As<SyncCheckpointRepository>),
@@ -1,4 +1,6 @@
<script lang="ts"> <script lang="ts">
import { locale } from 'svelte-i18n';
import { fileVaultLabel } from '#lib/utils/file-vault-i18n.ts';
import BottomInfo from '#lib/components/shared-components/side-bar/BottomInfo.svelte'; import BottomInfo from '#lib/components/shared-components/side-bar/BottomInfo.svelte';
import RecentAlbums from '#lib/components/shared-components/side-bar/RecentAlbums.svelte'; import RecentAlbums from '#lib/components/shared-components/side-bar/RecentAlbums.svelte';
import Sidebar from '#lib/components/sidebar/Sidebar.svelte'; import Sidebar from '#lib/components/sidebar/Sidebar.svelte';
@@ -46,6 +48,8 @@
<Sidebar ariaLabel={$t('primary')}> <Sidebar ariaLabel={$t('primary')}>
<NavbarItem title={$t('photos')} href={Route.photos()} icon={mdiImageMultipleOutline} activeIcon={mdiImageMultiple} /> <NavbarItem title={$t('photos')} href={Route.photos()} icon={mdiImageMultipleOutline} activeIcon={mdiImageMultiple} />
<NavbarItem title={fileVaultLabel($locale, 'title')} href={Route.files()} icon={mdiFolderOutline} />
<NavbarItem title={$t('explore')} href={Route.explore()} icon={mdiMagnify} /> <NavbarItem title={$t('explore')} href={Route.explore()} icon={mdiMagnify} />
{#if config.map.enabled} {#if config.map.enabled}
+77
View File
@@ -0,0 +1,77 @@
import { asMutation, queryClient } from '#lib/queries/client.ts';
import {
createFileFolder,
getBaseUrl,
listFileFolders,
listFiles,
permanentlyDeleteFile,
restoreFile,
trashFile,
updateFile,
type FileVaultEntryDto,
} from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const fileVaultKeys = { all: ['file-vault'] as const };
export const getFileVaultQuery = (parentId?: string, trash = false, offset = 0) =>
queryOptions({
queryKey: [...fileVaultKeys.all, parentId, trash, offset],
queryFn: ({ signal }) => listFiles({ parentId, trash: trash ? 'true' : 'false', offset }, { signal }),
});
export const getFileVaultFoldersQuery = () =>
queryOptions({ queryKey: [...fileVaultKeys.all, 'folders'], queryFn: ({ signal }) => listFileFolders({ signal }) });
const refresh = () => queryClient.invalidateQueries({ queryKey: fileVaultKeys.all });
const options = { onSuccess: refresh, meta: { error: { toast: false as const } } };
export const createFileFolderMutation = () => asMutation(createFileFolder, options);
export const updateFileMutation = () => asMutation(updateFile, options);
export const trashFileMutation = () => asMutation(trashFile, options);
export const restoreFileMutation = () => asMutation(restoreFile, options);
export const permanentlyDeleteFileMutation = () => asMutation(permanentlyDeleteFile, options);
export const fileVaultDownloadUrl = (id: string) => `${getBaseUrl()}/file-vault/${encodeURIComponent(id)}/download`;
export class FileVaultUploadError extends Error {
constructor(public status: number) {
super(`File upload failed (${status})`);
}
}
export const uploadVaultFile = (
file: File,
parentId: string | undefined,
onProgress: (bytes: number, total: number) => void,
signal: AbortSignal,
) =>
new Promise<FileVaultEntryDto>((resolve, reject) => {
const query = new URLSearchParams({ name: file.name });
if (parentId) {
query.set('parentId', parentId);
}
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
xhr.open('POST', `${getBaseUrl()}/file-vault/upload?${query}`);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.addEventListener('progress', (event) => onProgress(event.loaded, event.total || file.size));
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as FileVaultEntryDto);
} catch (error) {
reject(error);
}
} else {
reject(new FileVaultUploadError(xhr.status));
}
});
xhr.addEventListener('error', () => reject(new FileVaultUploadError(xhr.status)));
xhr.addEventListener('abort', () => reject(new DOMException('Upload cancelled', 'AbortError')));
xhr.addEventListener('loadend', () => signal.removeEventListener('abort', abort));
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
reject(new DOMException('Upload cancelled', 'AbortError'));
return;
}
xhr.send(file);
});
export const uploadVaultFileMutation = () => asMutation(uploadVaultFile, options);
+1
View File
@@ -35,6 +35,7 @@ const asQueryString = (params?: Record<string, QueryValue | undefined>) => {
}; };
export const Route = { export const Route = {
files: (params?: { folder?: string; trash?: boolean; offset?: number }) => '/files' + asQueryString(params),
// auth // auth
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params), login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + asQueryString(params),
logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params), logout: (params?: { continue?: string }) => '/auth/logout' + asQueryString(params),
+32
View File
@@ -0,0 +1,32 @@
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
const russian = {
title: 'Файлы',
description: 'Личные документы и рабочие папки',
root: 'Мои файлы',
new_folder: 'Новая папка',
upload_files: 'Загрузить файлы',
upload_folder: 'Загрузить папку',
empty: 'В этой папке пока нет файлов',
empty_trash: 'Корзина пуста',
move_to: 'Переместить в папку',
folder_name: 'Название папки',
private: 'Доступ к этим файлам есть только у вас.',
used: 'Занято',
uploading: 'Загрузка',
error: 'Не удалось выполнить действие. Проверьте соединение и повторите попытку.',
conflict: 'Такое имя уже существует. Переименуйте файл или выберите другую папку.',
unavailable: 'Файл или папка недоступны. Обновите список.',
quota: 'Недостаточно места или превышен допустимый размер файла.',
invalid: String.raw`Проверьте название и выбранную папку. Название не должно содержать / или \.`,
purge_confirm: 'Удалить навсегда? Восстановить этот файл или папку с содержимым будет невозможно.',
trash_confirm: 'Переместить в корзину? Папка будет перемещена вместе с содержимым.',
drop: 'Перетащите сюда файлы или папки',
limit: 'Максимальный размер одного файла',
selected: 'Выбрано файлов',
} as const;
export type FileVaultLabel = keyof typeof russian;
export const fileVaultLabel = (language: string | null | undefined, key: FileVaultLabel) =>
language?.startsWith('ru') ? russian[key] : get(t)(`file_vault.${key}`);
+428
View File
@@ -0,0 +1,428 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import UserPageLayout from '#lib/components/layouts/UserPageLayout.svelte';
import { createPreloadedQuery, queryClient } from '#lib/queries/client.ts';
import {
createFileFolderMutation,
fileVaultDownloadUrl,
fileVaultKeys,
getFileVaultFoldersQuery,
getFileVaultQuery,
permanentlyDeleteFileMutation,
restoreFileMutation,
trashFileMutation,
updateFileMutation,
uploadVaultFileMutation,
FileVaultUploadError,
} from '#lib/queries/file-vault.query.ts';
import { Route } from '#lib/route.ts';
import { fileVaultLabel } from '#lib/utils/file-vault-i18n.ts';
import type { FileVaultEntryDto, FileVaultFolderDto } from '@immich/sdk';
import { createMutation } from '@tanstack/svelte-query';
import { locale, t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
const parentId = $derived(page.url.searchParams.get('folder') ?? undefined);
const trash = $derived(page.url.searchParams.get('trash') === 'true');
const offset = $derived(Math.max(0, Number(page.url.searchParams.get('offset')) || 0));
const query = createPreloadedQuery(() => getFileVaultQuery(parentId, trash, offset));
const entries = $derived(query.data.entries);
const labels = $derived((key: Parameters<typeof fileVaultLabel>[1]) => fileVaultLabel($locale, key));
const createFolder = createMutation(createFileFolderMutation);
const update = createMutation(updateFileMutation);
const remove = createMutation(trashFileMutation);
const restore = createMutation(restoreFileMutation);
const purge = createMutation(permanentlyDeleteFileMutation);
const upload = createMutation(uploadVaultFileMutation);
let error = $state('');
let busy = $state(false);
let progress = $state('');
let abortController: AbortController | undefined;
let filesInput: HTMLInputElement;
let folderInput: HTMLInputElement;
let dialog: HTMLDialogElement;
let modal = $state<'create' | 'rename' | 'move' | 'trash' | 'purge' | null>(null);
let selected = $state<FileVaultEntryDto | undefined>();
let name = $state('');
let destination = $state('');
let folders = $state<FileVaultFolderDto[]>([]);
$effect(() => {
if (modal) {
dialog?.showModal();
} else {
dialog?.close();
}
});
onDestroy(() => abortController?.abort());
const size = (bytes: number) =>
bytes < 1024
? new Intl.NumberFormat($locale ?? 'ru', { style: 'unit', unit: 'byte', maximumFractionDigits: 0 }).format(bytes)
: new Intl.NumberFormat($locale ?? 'ru', {
style: 'unit',
unit: bytes >= 1024 ** 3 ? 'gigabyte' : bytes >= 1024 ** 2 ? 'megabyte' : 'kilobyte',
maximumFractionDigits: 1,
}).format(bytes / (bytes >= 1024 ** 3 ? 1024 ** 3 : bytes >= 1024 ** 2 ? 1024 ** 2 : 1024));
const folderPath = (folder: FileVaultFolderDto) => {
const parts = [folder.name];
const seen = new SvelteSet([folder.id]);
let parent = folder.parentId;
while (parent && !seen.has(parent)) {
seen.add(parent);
const item = folders.find((entry) => entry.id === parent);
if (!item) {
break;
}
parts.unshift(item.name);
parent = item.parentId;
}
return parts.join(' / ');
};
const isDescendant = (folder: FileVaultFolderDto) => {
const seen = new SvelteSet<string>();
let current: FileVaultFolderDto | undefined = folder;
while (current && !seen.has(current.id)) {
if (current.id === selected?.id) {
return true;
}
seen.add(current.id);
current = folders.find((entry) => entry.id === current?.parentId);
}
return false;
};
const showError = (failure: unknown) => {
const status = failure instanceof FileVaultUploadError ? failure.status : (failure as { status?: number })?.status;
error = labels(
status === 409
? 'conflict'
: status === 404
? 'unavailable'
: status === 413 || status === 507
? 'quota'
: status === 400
? 'invalid'
: 'error',
);
};
async function run(action: () => Promise<unknown>) {
error = '';
busy = true;
try {
await action();
modal = null;
} catch (error_) {
showError(error_);
} finally {
busy = false;
}
}
async function openModal(kind: NonNullable<typeof modal>, entry?: FileVaultEntryDto) {
selected = entry;
name = entry?.name ?? '';
destination = entry?.parentId ?? '';
error = '';
if (kind === 'move') {
try {
folders = await queryClient.fetchQuery(getFileVaultFoldersQuery());
} catch (error_) {
showError(error_);
return;
}
}
modal = kind;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
await run(async () => {
if (modal === 'create') {
await createFolder.mutateAsync([{ fileVaultCreateFolderDto: { name, parentId: parentId ?? null } }]);
}
if (selected && modal === 'rename') {
await update.mutateAsync([{ id: selected.id, fileVaultUpdateDto: { name } }]);
}
if (selected && modal === 'move') {
await update.mutateAsync([{ id: selected.id, fileVaultUpdateDto: { parentId: destination || null } }]);
}
if (selected && modal === 'trash') {
await remove.mutateAsync([{ id: selected.id }]);
}
if (selected && modal === 'purge') {
await purge.mutateAsync([{ id: selected.id }]);
}
});
}
async function uploadFiles(files: File[]) {
if (busy || trash || files.length === 0) {
return;
}
error = '';
busy = true;
abortController = new AbortController();
const uploadParent = parentId;
let completed = 0;
try {
const known = new SvelteMap<string, string>();
const existing = await queryClient.fetchQuery(getFileVaultFoldersQuery());
for (const folder of existing) {
known.set(`${folder.parentId ?? ''}\0${folder.name.toLocaleLowerCase()}`, folder.id);
}
for (const file of files) {
if (abortController.signal.aborted) {
break;
}
let target = uploadParent;
const parts = file.webkitRelativePath.split('/').slice(0, -1);
for (const part of parts) {
const key = `${target ?? ''}\0${part.toLocaleLowerCase()}`;
let id = known.get(key);
if (!id) {
const created = await createFolder.mutateAsync([
{ fileVaultCreateFolderDto: { name: part, parentId: target ?? null } },
]);
id = created.id;
known.set(key, id);
}
target = id;
}
if (file.size > query.data.maxFileBytes) {
throw new FileVaultUploadError(413);
}
await upload.mutateAsync([
file,
target,
(bytes, total) => {
progress = `${completed + 1}/${files.length} · ${file.name} · ${total ? Math.round((bytes / total) * 100) : 0}%`;
},
abortController.signal,
]);
completed++;
}
} catch (error_) {
if (!(error_ instanceof DOMException && error_.name === 'AbortError')) {
showError(error_);
}
} finally {
busy = false;
progress = '';
filesInput.value = '';
folderInput.value = '';
await queryClient.invalidateQueries({ queryKey: fileVaultKeys.all });
}
}
</script>
<UserPageLayout title={labels('title')} description={labels('description')}>
<section class="mx-auto w-full max-w-6xl p-4 md:p-6" aria-label={labels('title')}>
<div class="mb-5 flex flex-wrap items-center gap-2">
<a class="vault-button" aria-current={trash ? undefined : 'page'} href={Route.files()}>{labels('root')}</a>
<a class="vault-button" aria-current={trash ? 'page' : undefined} href={Route.files({ trash: true })}
>{$t('trash')}</a
>
{#if !trash}
<button type="button" class="vault-button" disabled={busy} onclick={() => openModal('create')}
>{labels('new_folder')}</button
>
<button type="button" class="vault-button vault-primary" disabled={busy} onclick={() => filesInput.click()}
>{labels('upload_files')}</button
>
<button type="button" class="vault-button" disabled={busy} onclick={() => folderInput.click()}
>{labels('upload_folder')}</button
>
{/if}
<input
bind:this={filesInput}
class="hidden"
type="file"
multiple
onchange={(event) => uploadFiles([...(event.currentTarget.files ?? [])])}
aria-label={labels('upload_files')}
/>
<input
bind:this={folderInput}
class="hidden"
type="file"
webkitdirectory
multiple
onchange={(event) => uploadFiles([...(event.currentTarget.files ?? [])])}
aria-label={labels('upload_folder')}
/>
</div>
{#if !trash}
<nav class="mb-4 flex flex-wrap gap-2 text-sm" aria-label={$t('folders')}>
<a href={Route.files()}>{labels('root')}</a>
{#each query.data.breadcrumbs as folder (folder.id)}<span>/</span><a href={Route.files({ folder: folder.id })}
>{folder.name}</a
>{/each}
</nav>
{/if}
<p class="mb-4 text-sm opacity-70">
{labels('private')}
{labels('used')}: {size(query.data.usedBytes)} · {labels('limit')}: {size(query.data.maxFileBytes)}
</p>
{#if error}<p role="alert" class="mb-4 rounded-lg bg-red-100 p-3 text-red-900 dark:bg-red-950 dark:text-red-100">
{error}
</p>{/if}
{#if busy && progress}<div role="status" class="mb-4 flex items-center gap-3">
<span>{labels('uploading')}: {progress}</span><button
type="button"
class="vault-button"
onclick={() => abortController?.abort()}>{$t('cancel')}</button
>
</div>{/if}
<div class="overflow-x-auto rounded-xl border border-gray-300 dark:border-gray-700">
<table class="w-full text-left text-sm">
<thead class="bg-gray-100 dark:bg-gray-900"
><tr
><th class="p-3">{$t('name')}</th><th class="p-3">{$t('file_size')}</th><th class="p-3"
><span class="sr-only">{$t('select')}</span></th
></tr
></thead
>
<tbody>
{#each entries as entry (entry.id)}
<tr class="border-t border-gray-200 dark:border-gray-700" data-file-id={entry.id}>
<td class="max-w-80 p-3 wrap-break-word">
{#if entry.isFolder}<span aria-hidden="true">📁 </span>{:else}<span aria-hidden="true">📄 </span>{/if}
{#if entry.isFolder && !trash}<a
class="font-medium underline-offset-4 hover:underline"
href={Route.files({ folder: entry.id })}>{entry.name}</a
>{:else}<span>{entry.name}</span>{/if}
</td>
<td class="p-3 whitespace-nowrap">{entry.isFolder ? '—' : size(entry.size)}</td>
<td class="p-3"
><div class="flex flex-wrap justify-end gap-2">
{#if trash}
<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => run(() => restore.mutateAsync([{ id: entry.id }]))}>{$t('restore')}</button
>
<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => openModal('rename', entry)}>{$t('rename')}</button
>
<button
type="button"
class="vault-button text-red-700 dark:text-red-300"
disabled={busy}
onclick={() => openModal('purge', entry)}>{$t('delete_permanently')}</button
>
{:else}
<a
class="vault-button"
href={fileVaultDownloadUrl(entry.id)}
download={entry.name + (entry.isFolder ? '.zip' : '')}>{$t('download')}</a
>
<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => openModal('rename', entry)}>{$t('rename')}</button
>
<button type="button" class="vault-button" disabled={busy} onclick={() => openModal('move', entry)}
>{$t('move')}</button
>
<button type="button" class="vault-button" disabled={busy} onclick={() => openModal('trash', entry)}
>{$t('delete')}</button
>
{/if}
</div></td
>
</tr>
{/each}
{#if entries.length === 0}<tr
><td colspan="3" class="p-12 text-center opacity-60">{labels(trash ? 'empty_trash' : 'empty')}</td></tr
>{/if}
</tbody>
</table>
</div>
<div class="mt-4 flex gap-2">
{#if offset > 0}<button
type="button"
class="vault-button"
disabled={busy}
onclick={() =>
goto(Route.files({ folder: parentId, trash: trash || undefined, offset: Math.max(0, offset - 100) }))}
>{$t('previous')}</button
>{/if}
{#if query.data.hasMore}<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => goto(Route.files({ folder: parentId, trash: trash || undefined, offset: offset + 100 }))}
>{$t('next')}</button
>{/if}
</div>
</section>
</UserPageLayout>
<dialog
bind:this={dialog}
onclose={() => (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"
>
<form onsubmit={submit}>
<h2 id="vault-dialog-title" class="mb-5 text-lg font-semibold">
{modal === 'create'
? labels('new_folder')
: modal === 'move'
? labels('move_to')
: modal === 'rename'
? $t('rename')
: modal === 'purge'
? $t('delete_permanently')
: $t('trash')}
</h2>
{#if modal === 'create' || modal === 'rename'}<label class="block"
>{$t('name')}<input
class="mt-2 w-full rounded-lg border bg-transparent p-3"
bind:value={name}
required
maxlength="240"
disabled={busy}
/></label
>{/if}
{#if modal === 'move'}<label class="block"
>{$t('folder')}<select
class="mt-2 w-full rounded-lg border bg-white p-3 dark:bg-gray-900"
bind:value={destination}
><option value="">{labels('root')}</option
>{#each folders.filter((folder) => !isDescendant(folder)) as folder (folder.id)}<option value={folder.id}
>{folderPath(folder)}</option
>{/each}</select
></label
>{/if}
{#if modal === 'trash' || modal === 'purge'}<p>{labels(modal === 'trash' ? 'trash_confirm' : 'purge_confirm')}</p>
<p class="mt-3 font-semibold break-all">{selected?.name}</p>{/if}
{#if error}<p role="alert" class="mt-3 text-red-600">{error}</p>{/if}
<div class="mt-6 flex justify-end gap-3">
<button type="button" class="vault-button" disabled={busy} onclick={() => (modal = null)}>{$t('cancel')}</button
><button type="submit" class="vault-button vault-primary" disabled={busy}>{$t('confirm')}</button>
</div>
</form>
</dialog>
<style>
.vault-button {
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
border-radius: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.875rem;
white-space: nowrap;
}
.vault-button:disabled {
opacity: 0.45;
cursor: wait;
}
.vault-button[aria-current='page'],
.vault-primary {
background: var(--color-immich-primary, #4258ca);
color: white;
}
</style>
+16
View File
@@ -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;