feat: add private file storage for web and Android
This commit is contained in:
@@ -1175,6 +1175,31 @@
|
||||
"file_name_or_extension": "File name or extension",
|
||||
"file_name_text": "File name",
|
||||
"file_size": "File size",
|
||||
"file_vault": {
|
||||
"conflict": "This name already exists. Rename the item or choose another folder.",
|
||||
"description": "Personal documents and work folders",
|
||||
"drop": "Drop files or folders here",
|
||||
"empty": "This folder is empty",
|
||||
"empty_trash": "Trash is empty",
|
||||
"error": "The action failed. Check your connection and try again.",
|
||||
"folder_name": "Folder name",
|
||||
"invalid": "Check the name and destination folder. Names cannot contain slash characters.",
|
||||
"limit": "Maximum file size",
|
||||
"move_to": "Move to folder",
|
||||
"new_folder": "New folder",
|
||||
"private": "Only you can access these files.",
|
||||
"purge_confirm": "Delete permanently? This item and its contents cannot be restored.",
|
||||
"quota": "Insufficient storage space or the maximum file size was exceeded.",
|
||||
"root": "My files",
|
||||
"selected": "Files selected",
|
||||
"title": "Files",
|
||||
"trash_confirm": "Move to trash? The folder and its contents will be moved together.",
|
||||
"unavailable": "This file or folder is unavailable. Refresh the list.",
|
||||
"upload_files": "Upload files",
|
||||
"upload_folder": "Upload folder",
|
||||
"uploading": "Uploading",
|
||||
"used": "Used"
|
||||
},
|
||||
"filename": "Filename",
|
||||
"filetype": "Filetype",
|
||||
"filter": "Filter",
|
||||
|
||||
@@ -31,6 +31,11 @@
|
||||
android:largeHeap="true" android:enableOnBackInvokedCallback="false" android:allowBackup="false"
|
||||
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" />
|
||||
<provider android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="${applicationId}.updates" android:exported="false"
|
||||
@@ -95,6 +100,17 @@
|
||||
<data android:mimeType="video/*" />
|
||||
</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 -->
|
||||
<intent-filter android:label="View in Immich">
|
||||
<action android:name="android.intent.action.VIEW" />
|
||||
|
||||
@@ -1,81 +1,82 @@
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||
import app.alextran.immich.background.BackgroundWorkerLockApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import app.alextran.immich.core.NetworkApiPlugin
|
||||
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
||||
import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
import app.alextran.immich.viewintent.ViewIntentPlugin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
registerPlugins(this, flutterEngine)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
HttpClientManager.initialize(ctx)
|
||||
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
|
||||
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
|
||||
val nativeSyncApiImpl =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
|
||||
NativeSyncApiImpl26(ctx)
|
||||
} else {
|
||||
NativeSyncApiImpl30(ctx)
|
||||
}
|
||||
val permissionApiImpl = PermissionApiImpl(ctx)
|
||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||
PermissionApi.setUp(messenger, permissionApiImpl)
|
||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(ViewIntentPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
val nativeApi =
|
||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||
nativeApi?.detachFromEngine()
|
||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||
permissionApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
package app.alextran.immich
|
||||
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.ext.SdkExtensions
|
||||
import app.alextran.immich.background.BackgroundEngineLock
|
||||
import app.alextran.immich.background.BackgroundWorkerApiImpl
|
||||
import app.alextran.immich.background.BackgroundWorkerFgHostApi
|
||||
import app.alextran.immich.background.BackgroundWorkerLockApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApi
|
||||
import app.alextran.immich.connectivity.ConnectivityApiImpl
|
||||
import app.alextran.immich.core.HttpClientManager
|
||||
import app.alextran.immich.core.ImmichPlugin
|
||||
import app.alextran.immich.core.NetworkApiPlugin
|
||||
import me.albemala.native_video_player.NativeVideoPlayerPlugin
|
||||
import app.alextran.immich.images.LocalImageApi
|
||||
import app.alextran.immich.images.LocalImagesImpl
|
||||
import app.alextran.immich.images.RemoteImageApi
|
||||
import app.alextran.immich.images.RemoteImagesImpl
|
||||
import app.alextran.immich.permission.PermissionApi
|
||||
import app.alextran.immich.permission.PermissionApiImpl
|
||||
import app.alextran.immich.sync.NativeSyncApi
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl26
|
||||
import app.alextran.immich.sync.NativeSyncApiImpl30
|
||||
import app.alextran.immich.viewintent.ViewIntentPlugin
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
|
||||
class MainActivity : FlutterFragmentActivity() {
|
||||
override fun configureFlutterEngine(flutterEngine: FlutterEngine) {
|
||||
super.configureFlutterEngine(flutterEngine)
|
||||
AppUpdateInstaller(this, flutterEngine.dartExecutor.binaryMessenger)
|
||||
flutterEngine.plugins.add(app.alextran.immich.files.FileVaultPlugin())
|
||||
registerPlugins(this, flutterEngine)
|
||||
}
|
||||
|
||||
override fun onNewIntent(intent: Intent) {
|
||||
super.onNewIntent(intent)
|
||||
setIntent(intent)
|
||||
}
|
||||
|
||||
companion object {
|
||||
fun registerPlugins(ctx: Context, flutterEngine: FlutterEngine) {
|
||||
HttpClientManager.initialize(ctx)
|
||||
NativeVideoPlayerPlugin.dataSourceFactory = HttpClientManager::createDataSourceFactory
|
||||
flutterEngine.plugins.add(NetworkApiPlugin())
|
||||
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
val backgroundEngineLockImpl = BackgroundEngineLock(ctx)
|
||||
BackgroundWorkerLockApi.setUp(messenger, backgroundEngineLockImpl)
|
||||
val nativeSyncApiImpl =
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R || SdkExtensions.getExtensionVersion(Build.VERSION_CODES.R) < 1) {
|
||||
NativeSyncApiImpl26(ctx)
|
||||
} else {
|
||||
NativeSyncApiImpl30(ctx)
|
||||
}
|
||||
val permissionApiImpl = PermissionApiImpl(ctx)
|
||||
NativeSyncApi.setUp(messenger, nativeSyncApiImpl)
|
||||
PermissionApi.setUp(messenger, permissionApiImpl)
|
||||
LocalImageApi.setUp(messenger, LocalImagesImpl(ctx))
|
||||
RemoteImageApi.setUp(messenger, RemoteImagesImpl(ctx))
|
||||
|
||||
BackgroundWorkerFgHostApi.setUp(messenger, BackgroundWorkerApiImpl(ctx))
|
||||
ConnectivityApi.setUp(messenger, ConnectivityApiImpl(ctx))
|
||||
|
||||
flutterEngine.plugins.add(ViewIntentPlugin())
|
||||
flutterEngine.plugins.add(backgroundEngineLockImpl)
|
||||
flutterEngine.plugins.add(nativeSyncApiImpl)
|
||||
flutterEngine.plugins.add(permissionApiImpl)
|
||||
}
|
||||
|
||||
fun cancelPlugins(flutterEngine: FlutterEngine) {
|
||||
val nativeApi =
|
||||
flutterEngine.plugins.get(NativeSyncApiImpl26::class.java) as ImmichPlugin?
|
||||
?: flutterEngine.plugins.get(NativeSyncApiImpl30::class.java) as ImmichPlugin?
|
||||
nativeApi?.detachFromEngine()
|
||||
val permissionApi = flutterEngine.plugins.get(PermissionApiImpl::class.java) as ImmichPlugin?
|
||||
permissionApi?.detachFromEngine()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
enum ShareIntentAttachmentType { image, video }
|
||||
enum ShareIntentAttachmentType { image, video, file }
|
||||
|
||||
enum UploadStatus { enqueued, running, complete, failed }
|
||||
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import 'dart:async';
|
||||
import 'dart:io';
|
||||
|
||||
import 'package:auto_route/auto_route.dart';
|
||||
import 'package:flutter/material.dart';
|
||||
@@ -9,6 +10,7 @@ import 'package:immich_mobile/domain/utils/event_stream.dart';
|
||||
import 'package:immich_mobile/extensions/build_context_extensions.dart';
|
||||
import 'package:immich_mobile/generated/translations.g.dart';
|
||||
import 'package:immich_mobile/presentation/pages/search/paginated_search.provider.dart';
|
||||
import 'package:immich_mobile/presentation/utils/file_vault_labels.dart';
|
||||
import 'package:immich_mobile/providers/haptic_feedback.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/album.provider.dart';
|
||||
import 'package:immich_mobile/providers/infrastructure/memory.provider.dart';
|
||||
@@ -55,6 +57,13 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
selectedIcon: Icon(Icons.space_dashboard_rounded, color: context.primaryColor),
|
||||
enabled: !isReadonlyModeEnabled,
|
||||
),
|
||||
if (Platform.isAndroid)
|
||||
NavigationDestination(
|
||||
label: FileVaultLabels(Localizations.localeOf(context).languageCode).text('title'),
|
||||
icon: const Icon(Icons.folder_outlined),
|
||||
selectedIcon: Icon(Icons.folder, color: context.primaryColor),
|
||||
enabled: !isReadonlyModeEnabled,
|
||||
),
|
||||
];
|
||||
|
||||
Widget navigationRail(TabsRouter tabsRouter) {
|
||||
@@ -77,7 +86,13 @@ class _TabShellPageState extends ConsumerState<TabShellPage> {
|
||||
}
|
||||
|
||||
return AutoTabsRouter(
|
||||
routes: const [MainTimelineRoute(), SearchRoute(), AlbumsRoute(), LibraryRoute()],
|
||||
routes: [
|
||||
const MainTimelineRoute(),
|
||||
const SearchRoute(),
|
||||
const AlbumsRoute(),
|
||||
const LibraryRoute(),
|
||||
if (Platform.isAndroid) FileVaultRoute(),
|
||||
],
|
||||
duration: const Duration(milliseconds: 600),
|
||||
transitionBuilder: (context, child, animation) => FadeTransition(opacity: animation, child: child),
|
||||
builder: (context, child) {
|
||||
|
||||
@@ -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) {
|
||||
if (attachments.any((attachment) => attachment.type == ShareIntentAttachmentType.file)) {
|
||||
router.removeWhere((route) => route.name == "FileVaultRoute");
|
||||
unawaited(router.push(FileVaultRoute(incomingPaths: attachments.map((attachment) => attachment.path).toList())));
|
||||
return;
|
||||
}
|
||||
router.removeWhere((route) => route.name == "ShareIntentRoute");
|
||||
clearAttachments();
|
||||
addAttachments(attachments);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import 'package:hooks_riverpod/hooks_riverpod.dart';
|
||||
|
||||
enum TabEnum { home, search, albums, library }
|
||||
enum TabEnum { home, search, albums, library, files }
|
||||
|
||||
/// Provides the currently active tab
|
||||
final tabProvider = StateProvider<TabEnum>((ref) => TabEnum.home);
|
||||
|
||||
@@ -36,7 +36,9 @@ class ShareHandlerRepository {
|
||||
|
||||
final type = attachment.type == SharedAttachmentType.image
|
||||
? ShareIntentAttachmentType.image
|
||||
: ShareIntentAttachmentType.video;
|
||||
: attachment.type == SharedAttachmentType.video
|
||||
? ShareIntentAttachmentType.video
|
||||
: ShareIntentAttachmentType.file;
|
||||
|
||||
final fileLength = File(attachment.path).lengthSync();
|
||||
|
||||
|
||||
@@ -49,6 +49,7 @@ import 'package:immich_mobile/presentation/pages/download_info.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/edit/edit.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/favorite.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/feature_message/whats_new.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/file_vault.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/library.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_album.page.dart';
|
||||
import 'package:immich_mobile/presentation/pages/local_timeline.page.dart';
|
||||
@@ -126,8 +127,10 @@ class AppRouter extends RootStackRouter {
|
||||
AutoRoute(page: SearchRoute.page, guards: [_authGuard, _duplicateGuard], maintainState: false),
|
||||
AutoRoute(page: LibraryRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: AlbumsRoute.page, guards: [_authGuard, _duplicateGuard]),
|
||||
AutoRoute(page: FileVaultRoute.page, guards: [_authGuard]),
|
||||
],
|
||||
),
|
||||
AutoRoute(page: FileVaultRoute.page, guards: [_authGuard]),
|
||||
AutoRoute(page: ProfilePictureCropRoute.page),
|
||||
AutoRoute(page: SettingsRoute.page, guards: [_duplicateGuard]),
|
||||
AutoRoute(page: SettingsSubRoute.page, guards: [_duplicateGuard]),
|
||||
|
||||
@@ -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
|
||||
|
||||
publish_to: 'none'
|
||||
version: 3.4.0-local.1+3040001
|
||||
version: 3.4.0-local.2+3040002
|
||||
|
||||
environment:
|
||||
sdk: '>=3.12.0 <4.0.0'
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
},
|
||||
"/file-vault": {
|
||||
"get": {
|
||||
"description": "List a private folder or trash with storage usage.",
|
||||
"operationId": "listFiles",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "offset",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minimum": 0,
|
||||
"maximum": 9007199254740991,
|
||||
"default": 0,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "parentId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "trash",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string",
|
||||
"enum": [
|
||||
"true",
|
||||
"false"
|
||||
]
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileVaultListResponseDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "List personal files",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/file-vault/folders": {
|
||||
"get": {
|
||||
"description": "List available destination folders owned by the current user.",
|
||||
"operationId": "listFileFolders",
|
||||
"parameters": [],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileVaultFolderDto"
|
||||
},
|
||||
"type": "array"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "List personal folders",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
},
|
||||
"post": {
|
||||
"description": "Create a folder without overwriting existing names.",
|
||||
"operationId": "createFileFolder",
|
||||
"parameters": [],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileVaultCreateFolderDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileVaultEntryDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Create a personal folder",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/file-vault/upload": {
|
||||
"post": {
|
||||
"description": "Stream an original file into private storage without media processing or overwriting.",
|
||||
"operationId": "uploadFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "name",
|
||||
"required": true,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"minLength": 1,
|
||||
"maxLength": 240,
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "parentId",
|
||||
"required": false,
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"201": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileVaultEntryDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Upload a personal file",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/file-vault/{id}": {
|
||||
"delete": {
|
||||
"description": "Move an item and its active descendants to the private trash.",
|
||||
"operationId": "trashFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Trash a personal item",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
},
|
||||
"patch": {
|
||||
"description": "Rename or move a file or folder while preserving the original contents.",
|
||||
"operationId": "updateFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileVaultUpdateDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"required": true
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/FileVaultEntryDto"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Rename or move a personal item",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/file-vault/{id}/download": {
|
||||
"get": {
|
||||
"description": "Download original bytes as an attachment after verifying ownership.",
|
||||
"operationId": "downloadFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"content": {
|
||||
"application/octet-stream": {
|
||||
"schema": {
|
||||
"format": "binary",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Download a personal file",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/file-vault/{id}/permanent": {
|
||||
"delete": {
|
||||
"description": "Permanently remove a trashed item and its contents.",
|
||||
"operationId": "permanentlyDeleteFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Permanently delete a personal item",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/file-vault/{id}/restore": {
|
||||
"post": {
|
||||
"description": "Restore a trashed item with its contents, or restore to the root if its parent is unavailable.",
|
||||
"operationId": "restoreFile",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "id",
|
||||
"required": true,
|
||||
"in": "path",
|
||||
"schema": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-4[0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12})$",
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"204": {
|
||||
"description": ""
|
||||
}
|
||||
},
|
||||
"security": [
|
||||
{
|
||||
"bearer": []
|
||||
},
|
||||
{
|
||||
"cookie": []
|
||||
},
|
||||
{
|
||||
"api_key": []
|
||||
}
|
||||
],
|
||||
"summary": "Restore a personal item",
|
||||
"tags": [
|
||||
"File Vault"
|
||||
],
|
||||
"x-immich-history": [
|
||||
{
|
||||
"version": "v3.4.0",
|
||||
"state": "Added"
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
"/futo-backups/auth/oidc/device": {
|
||||
"get": {
|
||||
"description": "Connect an existing FUTO Backups account to this server.",
|
||||
@@ -25965,6 +26445,154 @@
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileVaultCreateFolderDto": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"parentId": {
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"name"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileVaultEntryDto": {
|
||||
"properties": {
|
||||
"checksum": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"createdAt": {
|
||||
"type": "string"
|
||||
},
|
||||
"deletedAt": {
|
||||
"nullable": true,
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
},
|
||||
"isFolder": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parentId": {
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
},
|
||||
"size": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"updatedAt": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"checksum",
|
||||
"createdAt",
|
||||
"deletedAt",
|
||||
"id",
|
||||
"isFolder",
|
||||
"name",
|
||||
"parentId",
|
||||
"size",
|
||||
"updatedAt"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileVaultFolderDto": {
|
||||
"properties": {
|
||||
"id": {
|
||||
"format": "uuid",
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
},
|
||||
"name": {
|
||||
"type": "string"
|
||||
},
|
||||
"parentId": {
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"id",
|
||||
"name",
|
||||
"parentId"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileVaultListResponseDto": {
|
||||
"properties": {
|
||||
"breadcrumbs": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileVaultFolderDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"entries": {
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/FileVaultEntryDto"
|
||||
},
|
||||
"type": "array"
|
||||
},
|
||||
"hasMore": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"maxFileBytes": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
},
|
||||
"usedBytes": {
|
||||
"maximum": 9007199254740991,
|
||||
"minimum": -9007199254740991,
|
||||
"type": "integer"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"breadcrumbs",
|
||||
"entries",
|
||||
"hasMore",
|
||||
"maxFileBytes",
|
||||
"usedBytes"
|
||||
],
|
||||
"type": "object"
|
||||
},
|
||||
"FileVaultUpdateDto": {
|
||||
"properties": {
|
||||
"name": {
|
||||
"maxLength": 240,
|
||||
"minLength": 1,
|
||||
"type": "string"
|
||||
},
|
||||
"parentId": {
|
||||
"format": "uuid",
|
||||
"nullable": true,
|
||||
"pattern": "^([0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[1-8][0-9a-fA-F]{3}-[89abAB][0-9a-fA-F]{3}-[0-9a-fA-F]{12}|00000000-0000-0000-0000-000000000000|ffffffff-ffff-ffff-ffff-ffffffffffff)$",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"FilesystemListingItemDto": {
|
||||
"properties": {
|
||||
"isDirectory": {
|
||||
@@ -29329,6 +29957,7 @@
|
||||
"Permission": {
|
||||
"description": "List of permissions",
|
||||
"enum": [
|
||||
"fileVault.read",
|
||||
"all",
|
||||
"activity.create",
|
||||
"activity.read",
|
||||
|
||||
@@ -1772,6 +1772,37 @@ export type FaceDto = {
|
||||
/** Face ID */
|
||||
id: string;
|
||||
};
|
||||
export type FileVaultFolderDto = {
|
||||
id: string;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
};
|
||||
export type FileVaultEntryDto = {
|
||||
checksum: string | null;
|
||||
createdAt: string;
|
||||
deletedAt: string | null;
|
||||
id: string;
|
||||
isFolder: boolean;
|
||||
name: string;
|
||||
parentId: string | null;
|
||||
size: number;
|
||||
updatedAt: string;
|
||||
};
|
||||
export type FileVaultListResponseDto = {
|
||||
breadcrumbs: FileVaultFolderDto[];
|
||||
entries: FileVaultEntryDto[];
|
||||
hasMore: boolean;
|
||||
maxFileBytes: number;
|
||||
usedBytes: number;
|
||||
};
|
||||
export type FileVaultCreateFolderDto = {
|
||||
name: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
export type FileVaultUpdateDto = {
|
||||
name?: string;
|
||||
parentId?: string | null;
|
||||
};
|
||||
export type DeviceFlowEventDto = {
|
||||
backendId?: string;
|
||||
reason?: DeviceFlowFailureReason;
|
||||
@@ -5986,6 +6017,133 @@ export function reassignFacesById({ id, faceDto }: {
|
||||
body: faceDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* List personal files
|
||||
*/
|
||||
export function listFiles({ offset, parentId, trash }: {
|
||||
offset?: number;
|
||||
parentId?: string;
|
||||
trash?: "true" | "false";
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: FileVaultListResponseDto;
|
||||
}>(`/file-vault${QS.query(QS.explode({
|
||||
offset,
|
||||
parentId,
|
||||
trash
|
||||
}))}`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* List personal folders
|
||||
*/
|
||||
export function listFileFolders(opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: FileVaultFolderDto[];
|
||||
}>("/file-vault/folders", {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Create a personal folder
|
||||
*/
|
||||
export function createFileFolder({ fileVaultCreateFolderDto }: {
|
||||
fileVaultCreateFolderDto: FileVaultCreateFolderDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: FileVaultEntryDto;
|
||||
}>("/file-vault/folders", oazapfts.json({
|
||||
...opts,
|
||||
method: "POST",
|
||||
body: fileVaultCreateFolderDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Upload a personal file
|
||||
*/
|
||||
export function uploadFile({ name, parentId, body }: {
|
||||
name: string;
|
||||
parentId?: string;
|
||||
body: Blob;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 201;
|
||||
data: FileVaultEntryDto;
|
||||
}>(`/file-vault/upload${QS.query(QS.explode({
|
||||
name,
|
||||
parentId
|
||||
}))}`, {
|
||||
...opts,
|
||||
method: "POST",
|
||||
body
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Trash a personal item
|
||||
*/
|
||||
export function trashFile({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/file-vault/${encodeURIComponent(id)}`, {
|
||||
...opts,
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Rename or move a personal item
|
||||
*/
|
||||
export function updateFile({ id, fileVaultUpdateDto }: {
|
||||
id: string;
|
||||
fileVaultUpdateDto: FileVaultUpdateDto;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchJson<{
|
||||
status: 200;
|
||||
data: FileVaultEntryDto;
|
||||
}>(`/file-vault/${encodeURIComponent(id)}`, oazapfts.json({
|
||||
...opts,
|
||||
method: "PATCH",
|
||||
body: fileVaultUpdateDto
|
||||
})));
|
||||
}
|
||||
/**
|
||||
* Download a personal file
|
||||
*/
|
||||
export function downloadFile({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchBlob<{
|
||||
status: 200;
|
||||
data: Blob;
|
||||
}>(`/file-vault/${encodeURIComponent(id)}/download`, {
|
||||
...opts
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Permanently delete a personal item
|
||||
*/
|
||||
export function permanentlyDeleteFile({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/file-vault/${encodeURIComponent(id)}/permanent`, {
|
||||
...opts,
|
||||
method: "DELETE"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Restore a personal item
|
||||
*/
|
||||
export function restoreFile({ id }: {
|
||||
id: string;
|
||||
}, opts?: Oazapfts.RequestOpts) {
|
||||
return oazapfts.ok(oazapfts.fetchText(`/file-vault/${encodeURIComponent(id)}/restore`, {
|
||||
...opts,
|
||||
method: "POST"
|
||||
}));
|
||||
}
|
||||
/**
|
||||
* Connect a FUTO Backups account
|
||||
*/
|
||||
@@ -9338,6 +9496,7 @@ export enum BulkIdErrorReason {
|
||||
Validation = "validation"
|
||||
}
|
||||
export enum Permission {
|
||||
FileVaultRead = "fileVault.read",
|
||||
All = "all",
|
||||
ActivityCreate = "activity.create",
|
||||
ActivityRead = "activity.read",
|
||||
|
||||
@@ -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 { DuplicateController } from 'src/controllers/duplicate.controller.js';
|
||||
import { FaceController } from 'src/controllers/face.controller.js';
|
||||
import { FileVaultController } from 'src/controllers/file-vault.controller.js';
|
||||
import { IntegrityAdminController } from 'src/controllers/integrity-admin.controller.js';
|
||||
import { JobController } from 'src/controllers/job.controller.js';
|
||||
import { LibraryController } from 'src/controllers/library.controller.js';
|
||||
@@ -47,6 +48,7 @@ import { ViewController } from 'src/controllers/view.controller.js';
|
||||
import { WorkflowController } from 'src/controllers/workflow.controller.js';
|
||||
|
||||
export const controllers = [
|
||||
FileVaultController,
|
||||
ApiKeyController,
|
||||
ActivityController,
|
||||
AlbumController,
|
||||
|
||||
@@ -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' });
|
||||
|
||||
export enum Permission {
|
||||
FileVaultRead = 'fileVault.read',
|
||||
All = 'all',
|
||||
|
||||
ActivityCreate = 'activity.create',
|
||||
|
||||
@@ -399,6 +399,13 @@ set
|
||||
where
|
||||
"asset"."libraryId" is null
|
||||
and "asset"."ownerId" = "user"."id"
|
||||
) + (
|
||||
select
|
||||
coalesce(sum("size"), 0) as "usage"
|
||||
from
|
||||
"file_vault"
|
||||
where
|
||||
"file_vault"."ownerId" = "user"."id"
|
||||
),
|
||||
"updatedAt" = $1
|
||||
where
|
||||
|
||||
@@ -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 { EmailRepository } from 'src/repositories/email.repository.js';
|
||||
import { EventRepository } from 'src/repositories/event.repository.js';
|
||||
import { FileVaultRepository } from 'src/repositories/file-vault.repository.js';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository.js';
|
||||
import { JobRepository } from 'src/repositories/job.repository.js';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository.js';
|
||||
@@ -104,6 +105,7 @@ export const repositories = [
|
||||
SharedLinkRepository,
|
||||
SharedLinkAssetRepository,
|
||||
StackRepository,
|
||||
FileVaultRepository,
|
||||
StorageRepository,
|
||||
SyncRepository,
|
||||
SyncCheckpointRepository,
|
||||
|
||||
@@ -312,12 +312,19 @@ export class UserRepository {
|
||||
.updateTable('user')
|
||||
.set({
|
||||
quotaUsageInBytes: (eb) =>
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage'))
|
||||
.where('asset.libraryId', 'is', null)
|
||||
.where('asset.ownerId', '=', eb.ref('user.id')),
|
||||
eb(
|
||||
eb
|
||||
.selectFrom('asset')
|
||||
.leftJoin('asset_exif', 'asset_exif.assetId', 'asset.id')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>('asset_exif.fileSizeInByte'), eb.lit(0)).as('usage'))
|
||||
.where('asset.libraryId', 'is', null)
|
||||
.where('asset.ownerId', '=', eb.ref('user.id')),
|
||||
'+',
|
||||
eb
|
||||
.selectFrom('file_vault')
|
||||
.select((eb) => eb.fn.coalesce(eb.fn.sum<number>('size'), eb.lit(0)).as('usage'))
|
||||
.where('file_vault.ownerId', '=', eb.ref('user.id')),
|
||||
).$notNull(),
|
||||
updatedAt: new Date(),
|
||||
})
|
||||
.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 { ClusterGroupTable } from 'src/schema/tables/cluster-group.table.js';
|
||||
import { FaceSearchTable } from 'src/schema/tables/face-search.table.js';
|
||||
import { FileVaultDeleteTable, FileVaultTable } from 'src/schema/tables/file-vault.table.js';
|
||||
import { GeodataPlacesTable } from 'src/schema/tables/geodata-places.table.js';
|
||||
import { IntegrityReportTable } from 'src/schema/tables/integrity-report.table.js';
|
||||
import { LibraryTable } from 'src/schema/tables/library.table.js';
|
||||
@@ -104,6 +105,8 @@ import { WorkflowTable } from 'src/schema/tables/workflow.table.js';
|
||||
@Database({ name: 'immich' })
|
||||
export class ImmichDatabase {
|
||||
tables = [
|
||||
FileVaultTable,
|
||||
FileVaultDeleteTable,
|
||||
ActivityTable,
|
||||
AlbumAssetTable,
|
||||
AlbumAssetAuditTable,
|
||||
@@ -212,6 +215,8 @@ export interface Migrations {
|
||||
}
|
||||
|
||||
export interface DB {
|
||||
file_vault: FileVaultTable;
|
||||
file_vault_delete: FileVaultDeleteTable;
|
||||
kysely_migrations: { timestamp: string; name: string };
|
||||
|
||||
activity: ActivityTable;
|
||||
|
||||
@@ -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
|
||||
1792517020180-AddUpdateIdIndexes
|
||||
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 { EmailRepository } from 'src/repositories/email.repository.js';
|
||||
import { EventRepository } from 'src/repositories/event.repository.js';
|
||||
import { FileVaultRepository } from 'src/repositories/file-vault.repository.js';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository.js';
|
||||
import { JobRepository } from 'src/repositories/job.repository.js';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository.js';
|
||||
@@ -124,6 +125,7 @@ export const BASE_SERVICE_DEPENDENCIES = [
|
||||
SharedLinkRepository,
|
||||
SharedLinkAssetRepository,
|
||||
StackRepository,
|
||||
FileVaultRepository,
|
||||
StorageRepository,
|
||||
SyncRepository,
|
||||
SyncCheckpointRepository,
|
||||
@@ -190,6 +192,7 @@ export class BaseService {
|
||||
protected sharedLinkRepository: SharedLinkRepository,
|
||||
protected sharedLinkAssetRepository: SharedLinkAssetRepository,
|
||||
protected stackRepository: StackRepository,
|
||||
protected fileVaultRepository: FileVaultRepository,
|
||||
protected storageRepository: StorageRepository,
|
||||
protected syncRepository: SyncRepository,
|
||||
protected syncCheckpointRepository: SyncCheckpointRepository,
|
||||
@@ -265,6 +268,7 @@ export class BaseService {
|
||||
ctx.sharedLinkRepository,
|
||||
ctx.sharedLinkAssetRepository,
|
||||
ctx.stackRepository,
|
||||
ctx.fileVaultRepository,
|
||||
ctx.storageRepository,
|
||||
ctx.syncRepository,
|
||||
ctx.syncCheckpointRepository,
|
||||
|
||||
@@ -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({
|
||||
dataPath: StorageCore.getMediaLocation(),
|
||||
dataFolders: Object.values(StorageFolder),
|
||||
dataFolders: [...Object.values(StorageFolder), 'files'],
|
||||
libraries: libraries
|
||||
.filter((library) => !library.deletedAt)
|
||||
.map(({ id, name, importPaths, exclusionPatterns }) => ({ id, name, importPaths, exclusionPatterns })),
|
||||
|
||||
@@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js';
|
||||
import { DatabaseService } from 'src/services/database.service.js';
|
||||
import { DownloadService } from 'src/services/download.service.js';
|
||||
import { DuplicateService } from 'src/services/duplicate.service.js';
|
||||
import { FileVaultService } from 'src/services/file-vault.service.js';
|
||||
import { FutoBackupsService } from 'src/services/futo-backups.service.js';
|
||||
import { HlsService } from 'src/services/hls.service.js';
|
||||
import { IntegrityService } from 'src/services/integrity.service.js';
|
||||
@@ -55,6 +56,7 @@ import { WorkflowExecutionService } from 'src/services/workflow-execution.servic
|
||||
import { WorkflowService } from 'src/services/workflow.service.js';
|
||||
|
||||
export const services = [
|
||||
FileVaultService,
|
||||
ApiKeyService,
|
||||
ActivityService,
|
||||
AlbumService,
|
||||
|
||||
@@ -152,6 +152,9 @@ const checkOtherAccess = async (access: AccessRepository, request: OtherAccessRe
|
||||
const { auth, permission, ids } = request;
|
||||
|
||||
switch (permission) {
|
||||
case Permission.FileVaultRead: {
|
||||
return new Set([...ids].filter((id) => id === auth.user.id));
|
||||
}
|
||||
// uses album id
|
||||
case Permission.ActivityCreate: {
|
||||
return await access.activity.checkCreateAccess(auth.user.id, ids);
|
||||
|
||||
@@ -39,6 +39,7 @@ import { DatabaseRepository } from 'src/repositories/database.repository.js';
|
||||
import { DuplicateRepository } from 'src/repositories/duplicate.repository.js';
|
||||
import { EmailRepository } from 'src/repositories/email.repository.js';
|
||||
import { EventRepository } from 'src/repositories/event.repository.js';
|
||||
import { FileVaultRepository } from 'src/repositories/file-vault.repository.js';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository.js';
|
||||
import { JobRepository } from 'src/repositories/job.repository.js';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository.js';
|
||||
@@ -529,6 +530,7 @@ const newRealRepository = <T extends BaseServiceDeps[number]>(key: T, db: Kysely
|
||||
case NotificationRepository:
|
||||
case OcrRepository:
|
||||
case AuthChallengeRepository:
|
||||
case FileVaultRepository:
|
||||
case PartnerRepository:
|
||||
case PasskeyRepository:
|
||||
case PersonRepository:
|
||||
@@ -603,6 +605,7 @@ const newMockRepository = <T>(key: ClassConstructor<T>) => {
|
||||
case IntegrityRepository:
|
||||
case NotificationRepository:
|
||||
case OcrRepository:
|
||||
case FileVaultRepository:
|
||||
case PartnerRepository:
|
||||
case PasskeyRepository:
|
||||
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 { EmailRepository } from 'src/repositories/email.repository.js';
|
||||
import { EventRepository } from 'src/repositories/event.repository.js';
|
||||
import { FileVaultRepository } from 'src/repositories/file-vault.repository.js';
|
||||
import { IntegrityRepository } from 'src/repositories/integrity.repository.js';
|
||||
import { JobRepository } from 'src/repositories/job.repository.js';
|
||||
import { LibraryRepository } from 'src/repositories/library.repository.js';
|
||||
@@ -284,6 +285,7 @@ export type ServiceOverrides = {
|
||||
sharedLink: SharedLinkRepository;
|
||||
sharedLinkAsset: SharedLinkAssetRepository;
|
||||
stack: StackRepository;
|
||||
fileVault: FileVaultRepository;
|
||||
storage: StorageRepository;
|
||||
sync: SyncRepository;
|
||||
syncCheckpoint: SyncCheckpointRepository;
|
||||
@@ -375,6 +377,7 @@ export const getMocks = () => {
|
||||
sharedLink: automock(SharedLinkRepository),
|
||||
sharedLinkAsset: automock(SharedLinkAssetRepository),
|
||||
stack: automock(StackRepository),
|
||||
fileVault: automock(FileVaultRepository, { strict: true }),
|
||||
storage: newStorageRepositoryMock(),
|
||||
sync: automock(SyncRepository),
|
||||
syncCheckpoint: automock(SyncCheckpointRepository),
|
||||
@@ -452,6 +455,7 @@ export const newTestService = <T extends BaseService>(
|
||||
overrides.sharedLink || (mocks.sharedLink as As<SharedLinkRepository>),
|
||||
overrides.sharedLinkAsset || (mocks.sharedLinkAsset as As<SharedLinkAssetRepository>),
|
||||
overrides.stack || (mocks.stack as As<StackRepository>),
|
||||
overrides.fileVault || (mocks.fileVault as As<FileVaultRepository>),
|
||||
overrides.storage || (mocks.storage as As<StorageRepository>),
|
||||
overrides.sync || (mocks.sync as As<SyncRepository>),
|
||||
overrides.syncCheckpoint || (mocks.syncCheckpoint as As<SyncCheckpointRepository>),
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
<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 RecentAlbums from '#lib/components/shared-components/side-bar/RecentAlbums.svelte';
|
||||
import Sidebar from '#lib/components/sidebar/Sidebar.svelte';
|
||||
@@ -46,6 +48,8 @@
|
||||
<Sidebar ariaLabel={$t('primary')}>
|
||||
<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} />
|
||||
|
||||
{#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 = {
|
||||
files: (params?: { folder?: string; trash?: boolean; offset?: number }) => '/files' + asQueryString(params),
|
||||
// auth
|
||||
login: (params?: { continue?: string; autoLaunch?: 0 | 1 }) => '/auth/login' + 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