Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2800192c76 |
@@ -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>
|
||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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,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();
|
||||||
|
|
||||||
|
|||||||
@@ -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]),
|
||||||
|
|||||||
@@ -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
@@ -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'
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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' }),
|
||||||
|
) {}
|
||||||
@@ -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',
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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>;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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' : ''))}`,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })),
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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);
|
||||||
@@ -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),
|
||||||
|
|||||||
@@ -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}`);
|
||||||
@@ -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>
|
||||||
@@ -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;
|
||||||
Reference in New Issue
Block a user