Author SHA1 Message Date
Codex 2800192c76 feat: add private file storage for web and Android 2026-10-31 23:07:09 +03:00
Codex 916718b558 fix(web): normalize maintenance backup test formatting 2026-10-31 07:55:35 +03:00
Codex 8a5ef4c96e Prepare local 3.4.0 web and Android release 2026-10-31 07:42:26 +03:00
Santo ShakilandCodex a3a52a43ca fix(mobile): show 10-bit heic and avif originals on ios (#32821)
(cherry picked from commit ca25e8becc4bfb18d63ca7920d26a984eae0e16f)
2026-10-31 07:23:57 +03:00
Jason RasmussenandCodex e15bdf9af3 fix(ci): release notes script (#32827)
(cherry picked from commit f47b02f91d6d55e0b0281609c37e6b1676626115)
2026-10-31 07:23:57 +03:00
Zack PollardandCodex b3fe177869 feat: futo backups 1.12.1 with restic's parallel scan (#32830)
(cherry picked from commit ca99e909199cfcec87a6e5b613a94bde23847249)
2026-10-31 07:23:57 +03:00
Zack PollardandCodex f5f154f2d8 fix(server): single library exclusion matcher (#32081)
(cherry picked from commit c3dee6f98b7a231adc3d0716a287c4b47bc69d3e)
2026-10-31 07:23:13 +03:00
Zack PollardandCodex abfb6c0a0e fix(server): keep user motion files on embedded motion replace (#32079)
(cherry picked from commit ddb799e0a44d112ecacc4867780f31bb8f71dc4f)
2026-10-31 07:22:45 +03:00
Zack PollardandCodex 91e416b104 fix(server): live photo match library (#32080)
(cherry picked from commit 05658ecba8679f54c4b95deeec3940b54ab0ecf7)
2026-10-31 07:22:45 +03:00
Zack PollardandCodex 92cf23b642 fix(server): compress database backups on 75% of the cores, up to 32 (#32823)
(cherry picked from commit 02c0d4d392217b02b670afb0b4ce9941330120ec)
2026-10-31 07:22:45 +03:00
YarosandCodex e708a80474 fix(mobile): don't block startup when offline (#32652)
* fix(mobile): don't block startup when offline

* chore: implement suggestions

---------

Co-authored-by: Santo Shakil <santoshakil@users.noreply.github.com>
(cherry picked from commit 32b137ab3713545324873f552adb51b9ef2aecde)
2026-10-31 07:22:45 +03:00
arno0392andCodex 276d5620fd fix(server): convert to nv12 before hwupload for VAAPI with software decoding (#32474)
* fix(server): convert to nv12 before hwupload for VAAPI with software decoding

* test(server): cover nv12 conversion for VAAPI sw decode

* output nv12 directly

---------

Co-authored-by: mertalev <101130780+mertalev@users.noreply.github.com>
(cherry picked from commit 4bdc4480b2771a5a0dfed26e1149d680f6c204d9)
2026-10-31 07:22:45 +03:00
80 changed files with 4559 additions and 253 deletions
+3 -3
View File
@@ -154,8 +154,8 @@ jobs:
previous=$(grep -A1 -xF "${TAG}" <<< "${tags}" | tail -1)
echo "generating notes from ${previous}"
mise //:notes --tag "${TAG}" --previous "${previous}" > "${RUNNER_TEMP}/notes.md"
cat "${RUNNER_TEMP}/notes.md"
mise //:notes --tag "${TAG}" --previous "${previous}"
cat misc/release/notes.md
- name: Create draft release
env:
@@ -171,7 +171,7 @@ jobs:
--draft \
--verify-tag \
--prerelease="${PRERELEASE}" \
--notes-file "${RUNNER_TEMP}/notes.md" \
--notes-file misc/release/notes.md \
docker/docker-compose.yml \
docker/docker-compose.rootless.yml \
docker/example.env \
+1
View File
@@ -28,3 +28,4 @@ vite.config.js.timestamp-*
.devcontainer/.env*
*.tsbuildinfo
*.tsbuildInfo
misc/release/notes.md
+25
View File
@@ -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",
+1
View File
@@ -7,6 +7,7 @@ config_roots = [
"packages/cli",
"deployment",
"mobile",
"native",
"e2e",
"web",
"docs",
@@ -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>
@@ -1,3 +1,4 @@
import Accelerate
import Foundation
enum ImageProcessing {
@@ -11,4 +12,22 @@ enum ImageProcessing {
static let cancelledResult = Result<[String: Int64]?, any Error>.success(nil)
// Impeller shrinks each side to the GPU texture limit on its own, which squishes long images.
static let maxPixelSize = 16384
static func rgbaBuffer(_ image: CGImage, format: vImage_CGImageFormat) throws -> vImage_Buffer {
guard image.bitsPerComponent == 10 else {
return try vImage_Buffer(cgImage: image, format: format)
}
// vImage can't convert packed 10-bit images.
let buffer = try vImage_Buffer(width: image.width, height: image.height, bitsPerPixel: format.bitsPerPixel)
guard let context = CGContext(data: buffer.data, width: image.width, height: image.height, bitsPerComponent: Int(format.bitsPerComponent), bytesPerRow: buffer.rowBytes, space: format.colorSpace.takeUnretainedValue(), bitmapInfo: format.bitmapInfo.rawValue) else {
buffer.free()
throw vImage.Error.invalidImageFormat
}
context.setRenderingIntent(format.renderingIntent)
context.setBlendMode(.copy)
context.draw(image, in: CGRect(x: 0, y: 0, width: image.width, height: image.height))
return buffer
}
}
@@ -165,7 +165,7 @@ class LocalImageApiImpl: LocalImageApi {
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: Self.rgbaFormat)
let buffer = try ImageProcessing.rgbaBuffer(cgImage, format: Self.rgbaFormat)
if request.isCancelled {
buffer.free()
@@ -148,7 +148,7 @@ class RemoteImageApiImpl: NSObject, RemoteImageApi {
}
do {
let buffer = try vImage_Buffer(cgImage: cgImage, format: rgbaFormat)
let buffer = try ImageProcessing.rgbaBuffer(cgImage, format: rgbaFormat)
if request.isCancelled {
buffer.free()
+160
View File
@@ -0,0 +1,160 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:http/http.dart' as http;
import 'package:immich_mobile/domain/models/file_vault.model.dart';
import 'package:immich_mobile/platform/file_vault_api.g.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
import 'package:path_provider/path_provider.dart';
class FileVaultApiData {
FileVaultApiData(this._apiService, this.platform, {Future<Directory> Function()? temporaryDirectory, this.onChanged})
: _temporaryDirectory = temporaryDirectory ?? getTemporaryDirectory;
final ApiService _apiService;
final FileVaultPlatform platform;
final Future<Directory> Function() _temporaryDirectory;
final void Function()? onChanged;
FileVaultApi get _api => FileVaultApi(_apiService.apiClient);
VaultEntry _entry(FileVaultEntryDto dto) => VaultEntry(
id: dto.id,
name: dto.name,
parentId: dto.parentId,
isFolder: dto.isFolder,
size: dto.size,
checksum: dto.checksum,
);
VaultFolder _folder(FileVaultFolderDto dto) => VaultFolder(id: dto.id, name: dto.name, parentId: dto.parentId);
Future<VaultListing> list({String? parentId, bool trash = false, int offset = 0}) async {
final dto = await _api.listFiles(parentId: parentId, trash: trash ? 'true' : 'false', offset: offset);
if (dto == null) {
throw const FormatException('Empty file listing');
}
return VaultListing(
entries: dto.entries.map(_entry).toList(),
breadcrumbs: dto.breadcrumbs.map(_folder).toList(),
hasMore: dto.hasMore,
usedBytes: dto.usedBytes,
maxFileBytes: dto.maxFileBytes,
);
}
Future<List<VaultFolder>> folders() async => (await _api.listFileFolders() ?? []).map(_folder).toList();
Future<void> createFolder(String name, String? parentId) async {
await _api.createFileFolder(FileVaultCreateFolderDto(name: name, parentId: Optional.present(parentId)));
onChanged?.call();
}
Future<void> rename(String id, String name) async {
await _api.updateFile(id, FileVaultUpdateDto(name: Optional.present(name)));
onChanged?.call();
}
Future<void> move(String id, String? parentId) async {
await _api.updateFile(id, FileVaultUpdateDto(parentId: Optional.present(parentId)));
onChanged?.call();
}
Future<void> trash(String id) async {
await _api.trashFile(id);
onChanged?.call();
}
Future<void> restore(String id) async {
await _api.restoreFile(id);
onChanged?.call();
}
Future<void> purge(String id) async {
await _api.permanentlyDeleteFile(id);
onChanged?.call();
}
Future<void> upload(String path, String name, String? parentId, void Function(int, int) onProgress) async {
final file = File(path);
final length = await file.length();
var sent = 0;
final body = http.MultipartFile(
'file',
file.openRead().map((chunk) {
sent += chunk.length;
onProgress(sent, length);
return chunk;
}),
length,
filename: name,
);
final response = await _apiService.apiClient.invokeAPI(
'/file-vault/upload',
'POST',
[QueryParam('name', name), if (parentId != null) QueryParam('parentId', parentId)],
body,
{},
{},
'application/octet-stream',
);
if (response.statusCode < 200 || response.statusCode >= 300) {
throw ApiException(response.statusCode, utf8.decode(response.bodyBytes));
}
final dto = FileVaultEntryDto.fromJson(jsonDecode(utf8.decode(response.bodyBytes)));
if (dto == null) {
throw const FormatException('Missing upload response');
}
onChanged?.call();
final hash = await sha256.bind(file.openRead()).first;
if (dto.checksum != hash.toString()) {
throw const FormatException('File integrity verification failed');
}
}
Future<File> download(VaultEntry entry, void Function(int, int) onProgress) async {
if (!RegExp(r'^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$').hasMatch(entry.id) ||
entry.size < 0) {
throw const FormatException('Invalid file metadata');
}
final temporary = await _temporaryDirectory();
final directory = Directory(p.join(temporary.path, 'file-vault-downloads', entry.id));
await directory.create(recursive: true);
final target = File(p.join(directory.path, p.basename(entry.name) + (entry.isFolder ? '.zip' : '')));
final partial = File('${target.path}.part');
final request = http.Request('GET', Uri.parse('${_apiService.apiClient.basePath}/file-vault/${entry.id}/download'));
request.headers.addAll(_apiService.apiClient.defaultHeaderMap);
final response = await _apiService.apiClient.client.send(request);
if (response.statusCode != 200) {
await response.stream.drain<void>();
throw ApiException(response.statusCode, 'Download failed');
}
var received = 0;
final output = partial.openWrite();
try {
await output.addStream(
response.stream.map((chunk) {
received += chunk.length;
if (!entry.isFolder && received > entry.size) {
throw const FormatException('Download exceeds the expected size');
}
onProgress(received, entry.size);
return chunk;
}),
);
await output.flush();
await output.close();
final hash = await sha256.bind(partial.openRead()).first;
if (entry.isFolder ? received == 0 : received != entry.size || hash.toString() != entry.checksum) {
throw const FormatException('File integrity verification failed');
}
return await partial.rename(target.path);
} catch (_) {
await output.close();
if (partial.existsSync()) {
await partial.delete();
}
rethrow;
}
}
}
@@ -0,0 +1,41 @@
class VaultEntry {
const VaultEntry({
required this.id,
required this.name,
required this.parentId,
required this.isFolder,
required this.size,
required this.checksum,
});
final String id;
final String name;
final String? parentId;
final bool isFolder;
final int size;
final String? checksum;
}
class VaultFolder {
const VaultFolder({required this.id, required this.name, required this.parentId});
final String id;
final String name;
final String? parentId;
}
class VaultListing {
const VaultListing({
required this.entries,
required this.breadcrumbs,
required this.hasMore,
required this.usedBytes,
required this.maxFileBytes,
});
final List<VaultEntry> entries;
final List<VaultFolder> breadcrumbs;
final bool hasMore;
final int usedBytes;
final int maxFileBytes;
}
@@ -0,0 +1,13 @@
import 'package:hooks_riverpod/hooks_riverpod.dart';
import 'package:immich_mobile/data/server/file_vault_api.dart';
import 'package:immich_mobile/platform/file_vault_api.g.dart';
import 'package:immich_mobile/providers/api.provider.dart';
final fileVaultRevisionProvider = StateProvider<int>((_) => 0);
final fileVaultRepositoryProvider = Provider(
(ref) => FileVaultApiData(
ref.watch(apiServiceProvider),
FileVaultPlatform(),
onChanged: () => ref.read(fileVaultRevisionProvider.notifier).state++,
),
);
@@ -6,7 +6,7 @@ import 'package:path/path.dart';
part 'share_intent_attachment.model.freezed.dart';
enum ShareIntentAttachmentType { image, video }
enum ShareIntentAttachmentType { image, video, file }
enum UploadStatus { enqueued, running, complete, failed }
@@ -299,7 +299,7 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
@override
void initState() {
super.initState();
unawaited(ref.read(authProvider.notifier).setOpenApiServiceEndpoint().whenComplete(() => resumeSession()));
unawaited(resumeSession());
}
Future<void> resumeSession() async {
@@ -314,13 +314,28 @@ class SplashScreenPageState extends ConsumerState<SplashScreenPage> {
final backupNotifier = ref.read(backupProvider.notifier);
final lifeCycle = ref.read(appStateProvider.notifier);
final viewIntentHandler = ref.read(viewIntentHandlerProvider);
final authNotifier = ref.read(authProvider.notifier);
unawaited(
ref
.read(authProvider.notifier)
.saveAuthInfo(accessToken: accessToken)
authNotifier
.setOpenApiServiceEndpoint()
.catchError((Object error, StackTrace stackTrace) {
log.warning('Failed to resolve server endpoint', error, stackTrace);
return null;
})
.then((_) async {
if (Store.tryGet(StoreKey.accessToken) != accessToken) {
return false;
}
await authNotifier.saveAuthInfo(accessToken: accessToken);
return true;
})
.then(
(_) async {
(sessionResumed) async {
if (!sessionResumed) {
return;
}
try {
wsProvider.connect();
unawaited(infoProvider.getServerInfo());
+16 -1
View File
@@ -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 -1
View File
@@ -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();
+3
View File
@@ -49,6 +49,7 @@ import 'package:immich_mobile/presentation/pages/download_info.page.dart';
import 'package:immich_mobile/presentation/pages/edit/edit.page.dart';
import 'package:immich_mobile/presentation/pages/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]),
+4 -2
View File
@@ -115,9 +115,11 @@ class ApiService {
Future<bool> _isEndpointAvailable(String serverUrl) async {
final endpoint = serverUrl.endsWith('/api') ? serverUrl : '$serverUrl/api';
// Probe with a throwaway client so the shared client keeps pointing at the active endpoint
final probeClient = ApiClient(basePath: endpoint)..client = NetworkRepository.client;
try {
setEndpoint(endpoint);
await serverInfoApi.pingServer().timeout(const Duration(seconds: 5));
await ServerApi(probeClient).pingServer().timeout(const Duration(seconds: 5));
} on TimeoutException catch (_) {
return false;
} on SocketException catch (_) {
+28
View File
@@ -0,0 +1,28 @@
import 'package:pigeon/pigeon.dart';
class VaultPickedFile {
String path;
String name;
VaultPickedFile(this.path, this.name);
}
@ConfigurePigeon(
PigeonOptions(
dartOut: 'lib/platform/file_vault_api.g.dart',
kotlinOut: 'android/app/src/main/kotlin/app/alextran/immich/files/FileVaultApi.g.kt',
kotlinOptions: KotlinOptions(package: 'app.alextran.immich.files', includeErrorClass: true),
dartOptions: DartOptions(),
dartPackageName: 'immich_mobile',
),
)
@HostApi()
abstract class FileVaultPlatform {
@async
List<VaultPickedFile> pickFiles();
@async
bool saveFile(String sourcePath, String suggestedName);
void openFile(String sourcePath, String suggestedName);
}
+1 -1
View File
@@ -2,7 +2,7 @@ name: immich_mobile
description: Immich - selfhosted backup media file on mobile phone
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'
+163
View File
@@ -0,0 +1,163 @@
import 'dart:convert';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:http/http.dart' as http;
import 'package:http/testing.dart';
import 'package:immich_mobile/data/server/file_vault_api.dart';
import 'package:immich_mobile/domain/models/file_vault.model.dart';
import 'package:immich_mobile/platform/file_vault_api.g.dart';
import 'package:immich_mobile/services/api.service.dart';
import 'package:openapi/api.dart';
import 'package:path/path.dart' as p;
class TestVaultApiService extends Fake implements ApiService {
TestVaultApiService(this.client);
final ApiClient client;
@override
ApiClient get apiClient => client;
}
void main() {
late Directory temporary;
setUp(() async {
temporary = await Directory.systemTemp.createTemp('immich-file-vault-test-');
});
tearDown(() async {
await temporary.delete(recursive: true);
});
FileVaultApiData repository(Future<http.Response> Function(http.Request) handler) {
final client = ApiClient(basePath: 'https://immich.example/api')..client = MockClient(handler);
client.addDefaultHeader('Authorization', 'Bearer test-session');
return FileVaultApiData(
TestVaultApiService(client),
FileVaultPlatform(),
temporaryDirectory: () async => temporary,
);
}
Map<String, Object?> entry({List<int> bytes = const [], String name = 'Работа.dwg'}) => {
'id': '00000000-0000-4000-8000-000000000001',
'parentId': null,
'name': name,
'isFolder': false,
'size': bytes.length,
'checksum': sha256.convert(bytes).toString(),
'createdAt': '2026-10-31T00:00:00.000Z',
'updatedAt': '2026-10-31T00:00:00.000Z',
'deletedAt': null,
};
test('renaming preserves the current folder and moving to root sends explicit null', () async {
final requests = <Map<String, dynamic>>[];
final data = repository((request) async {
requests.add(jsonDecode(request.body) as Map<String, dynamic>);
return http.Response(jsonEncode(entry()), 200, headers: {'content-type': 'application/json; charset=utf-8'});
});
await data.rename('00000000-0000-4000-8000-000000000001', 'Renamed.pdf');
await data.move('00000000-0000-4000-8000-000000000001', null);
expect(requests, [
{'name': 'Renamed.pdf'},
{'parentId': null},
]);
});
test('uploads original binary bytes with the private session and verifies the returned hash', () async {
final bytes = [0, 1, 255, 128, 13, 10, 0];
final file = File(p.join(temporary.path, 'Работа.dwg'));
await file.writeAsBytes(bytes);
final data = repository((request) async {
expect(request.url.queryParameters['name'], 'Работа.dwg');
expect(request.headers['Authorization'], 'Bearer test-session');
expect(request.bodyBytes, bytes);
return http.Response(
jsonEncode(entry(bytes: bytes)),
201,
headers: {'content-type': 'application/json; charset=utf-8'},
);
});
var progress = 0;
await data.upload(file.path, 'Работа.dwg', null, (sent, total) {
progress = sent;
expect(total, bytes.length);
});
expect(progress, bytes.length);
});
test('reports rejected uploads rather than treating them as successful', () async {
final file = File(p.join(temporary.path, 'private.pdf'));
await file.writeAsString('private');
final data = repository((_) async => http.Response('{"message":"Name exists"}', 409));
await expectLater(
data.upload(file.path, 'private.pdf', null, (_, _) {}),
throwsA(isA<ApiException>().having((error) => error.code, 'code', 409)),
);
});
test('does not confirm an upload when the returned original checksum differs', () async {
final file = File(p.join(temporary.path, 'private.pdf'));
await file.writeAsString('private');
final data = repository(
(_) async => http.Response(
jsonEncode(entry(bytes: [1, 2, 3])),
201,
headers: {'content-type': 'application/json; charset=utf-8'},
),
);
await expectLater(data.upload(file.path, 'private.pdf', null, (_, _) {}), throwsFormatException);
});
test('downloads into private temporary storage and validates original bytes', () async {
final bytes = [255, 0, 1, 13, 10, 128];
final data = repository((request) async {
expect(request.headers['Authorization'], 'Bearer test-session');
return http.Response.bytes(bytes, 200);
});
final file = VaultEntry(
id: '00000000-0000-4000-8000-000000000001',
name: 'Работа.zip',
parentId: null,
isFolder: false,
size: bytes.length,
checksum: sha256.convert(bytes).toString(),
);
final result = await data.download(file, (_, _) {});
expect(p.isWithin(temporary.path, result.path), isTrue);
expect(await result.readAsBytes(), bytes);
});
test('removes partial downloads when size or checksum verification fails', () async {
final data = repository((_) async => http.Response.bytes([1, 2], 200));
const file = VaultEntry(
id: '00000000-0000-4000-8000-000000000001',
name: 'broken.pdf',
parentId: null,
isFolder: false,
size: 3,
checksum: 'invalid',
);
await expectLater(data.download(file, (_, _) {}), throwsFormatException);
expect(await temporary.list(recursive: true).where((entry) => entry is File).toList(), isEmpty);
});
test('rejects a server-supplied path traversal identifier before any filesystem or network access', () async {
var requested = false;
final data = repository((_) async {
requested = true;
return http.Response('private', 200);
});
const file = VaultEntry(
id: '../../outside',
name: 'database',
parentId: null,
isFolder: false,
size: 7,
checksum: null,
);
await expectLater(data.download(file, (_, _) {}), throwsFormatException);
expect(requested, isFalse);
expect(await temporary.list().toList(), isEmpty);
});
}
+672 -1
View File
@@ -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.",
@@ -8490,6 +8970,45 @@
"x-immich-state": "Internal"
}
},
"/futo-backups/onboarding/restore/continue": {
"post": {
"description": "Record that the user has moved on from a finished restore, so it is no longer offered on reload.",
"operationId": "futoBackupsContinueAfterRestore",
"parameters": [],
"responses": {
"201": {
"description": ""
}
},
"security": [
{
"bearer": []
},
{
"cookie": []
},
{
"api_key": []
}
],
"summary": "Continue after a restore",
"tags": [
"Backups"
],
"x-immich-admin-only": true,
"x-immich-history": [
{
"version": "v3.4.0",
"state": "Added"
},
{
"version": "v3.4.0",
"state": "Internal"
}
],
"x-immich-state": "Internal"
}
},
"/futo-backups/onboarding/skip": {
"post": {
"description": "Skip the optional configuration steps of onboarding.",
@@ -21249,7 +21768,7 @@
"info": {
"title": "Immich",
"description": "Immich API",
"version": "3.3.5",
"version": "3.4.0-rc.0",
"contact": {}
},
"tags": [
@@ -25926,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": {
@@ -28795,6 +29462,9 @@
"requiresAuthentication": {
"type": "boolean"
},
"restoreJobId": {
"type": "string"
},
"status": {
"$ref": "#/components/schemas/BootstrapStatus"
}
@@ -29287,6 +29957,7 @@
"Permission": {
"description": "List of permissions",
"enum": [
"fileVault.read",
"all",
"activity.create",
"activity.read",
+3 -1
View File
@@ -1,5 +1,6 @@
import { Command, Option } from 'commander';
import {
FileSystem,
GitClient,
GitHubClient,
type NotesOptions,
@@ -63,10 +64,11 @@ export const cli = (argv: string[]) => {
.action(async ({ token, ...options }: NotesOptions & { token: string }) => {
try {
const service = new NotesService(
new FileSystem(),
new GitClient(),
new GitHubClient(token),
);
process.stdout.write(await service.handleNotes(options));
await service.handleNotes(options);
} catch (error) {
console.error(error instanceof Error ? error.message : error);
process.exit(1);
+20 -2
View File
@@ -2,6 +2,7 @@ import type { Public } from 'src/types.js';
import { beforeEach, describe, expect, it, type Mocked, vi } from 'vitest';
import {
type Contributor,
type FileSystem,
type GitClient,
type GitHubClient,
NotesService,
@@ -37,6 +38,7 @@ const changelog = (...lines: string[]) =>
const body = (notes: string) => notes.slice(notes.indexOf("## What's Changed"));
describe(NotesService.name, () => {
let fs: Mocked<Public<FileSystem>>;
let git: Mocked<Public<GitClient>>;
let github: Mocked<Public<GitHubClient>>;
let sut: NotesService;
@@ -88,12 +90,16 @@ describe(NotesService.name, () => {
github.getFirstTimers.mockResolvedValue(firstTimers);
};
const run = () => sut.handleNotes({ tag: 'v3.3.0', previous: 'v3.2.4' });
const run = async () => {
await sut.handleNotes({ tag: 'v3.3.0', previous: 'v3.2.4' });
return fs.writeFileSync.mock.lastCall?.[1] as string;
};
beforeEach(() => {
fs = { writeFileSync: vi.fn() };
git = { log: vi.fn() };
github = { getPullRequests: vi.fn(), getFirstTimers: vi.fn() };
sut = new NotesService(git, github);
sut = new NotesService(fs, git, github);
});
it('should compare the previous tag with the new tag', async () => {
@@ -104,6 +110,18 @@ describe(NotesService.name, () => {
expect(git.log).toHaveBeenCalledWith('v3.2.4', 'v3.3.0');
});
it('should write the notes to misc/release/notes.md', async () => {
setup([{ number: 1 }]);
await run();
expect(fs.writeFileSync).toHaveBeenCalledOnce();
expect(fs.writeFileSync).toHaveBeenCalledWith(
expect.stringMatching(/\/misc\/release\/notes\.md$/),
expect.any(String),
);
});
it('should start with the template and end with the full changelog', async () => {
setup([{ number: 1 }]);
+11 -2
View File
@@ -1,5 +1,5 @@
import { execFileSync } from 'node:child_process';
import { existsSync, readFileSync } from 'node:fs';
import { existsSync, readFileSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path';
import { fileURLToPath } from 'node:url';
import type { Public } from 'src/types.js';
@@ -15,6 +15,7 @@ const REPO = 'immich-app/immich';
const Files = {
Config: join(root, '.github/release.yml'),
Template: join(root, 'misc/release/notes.tmpl'),
Output: join(root, 'misc/release/notes.md'),
};
export type NotesOptions = {
@@ -55,6 +56,12 @@ type PullRequestNode = {
const PULL_REQUEST_BATCH = 50;
const SEARCH_BATCH = 20;
export class FileSystem {
writeFileSync(path: string, data: string) {
writeFileSync(path, data);
}
}
export class GitClient {
log(previous: string, tag: string) {
return execFileSync(
@@ -189,6 +196,7 @@ export class GitHubClient {
export class NotesService {
constructor(
private fs: Public<FileSystem>,
private git: Public<GitClient>,
private github: Public<GitHubClient>,
) {}
@@ -198,8 +206,9 @@ export class NotesService {
const pulls = await this.getPullRequests(commits);
const changes = this.selectChanges(commits, pulls);
const firstTimers = await this.getFirstTimers(changes);
const notes = this.render({ tag, previous, changes, firstTimers });
return this.render({ tag, previous, changes, firstTimers });
this.fs.writeFileSync(Files.Output, notes);
}
private parseCommits(log: string): Commit[] {
+170 -1
View File
@@ -1,6 +1,6 @@
/**
* Immich
* 3.3.3
* 3.4.0-rc.0
* DO NOT MODIFY - This file has been generated using oazapfts.
* See https://www.npmjs.com/package/oazapfts
*/
@@ -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;
@@ -2006,6 +2037,7 @@ export type OnboardingStatusResponseDto = {
hasTelemetry: TelemetryLevel;
isAuthenticated: boolean;
requiresAuthentication: boolean;
restoreJobId?: string;
status: BootstrapStatus;
};
export type CurrentRecoveryKeyResponse = {
@@ -5985,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
*/
@@ -6297,6 +6456,15 @@ export function futoBackupsReportError(opts?: Oazapfts.RequestOpts) {
method: "POST"
}));
}
/**
* Continue after a restore
*/
export function futoBackupsContinueAfterRestore(opts?: Oazapfts.RequestOpts) {
return oazapfts.ok(oazapfts.fetchText("/futo-backups/onboarding/restore/continue", {
...opts,
method: "POST"
}));
}
/**
* Skip optional backup onboarding
*/
@@ -9328,6 +9496,7 @@ export enum BulkIdErrorReason {
Validation = "validation"
}
export enum Permission {
FileVaultRead = "fileVault.read",
All = "all",
ActivityCreate = "activity.create",
ActivityRead = "activity.read",
+10 -10
View File
@@ -566,8 +566,8 @@ importers:
specifier: 2.0.0-rc13
version: 2.0.0-rc13
'@futo-org/backups-orchestrator-api':
specifier: 1.11.5
version: 1.11.5(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(@nestjs/schedule@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(supports-color@8.1.1)
specifier: 1.12.1
version: 1.12.1(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(@nestjs/schedule@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(supports-color@8.1.1)
'@immich/plugin-sdk':
specifier: workspace:*
version: link:../packages/plugin-sdk
@@ -957,8 +957,8 @@ importers:
specifier: ^3.0.0
version: 3.5.20
'@futo-org/backups-orchestrator-ui':
specifier: 1.11.5
version: 1.11.5(@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3))(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1))
specifier: 1.12.1
version: 1.12.1(@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3))(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1))
'@immich/justified-layout-wasm':
specifier: ^0.4.3
version: 0.4.3
@@ -2944,8 +2944,8 @@ packages:
'@futo-org/backups-api-client@1.12.1':
resolution: {integrity: sha512-y1AtOftdZnFpoU7YRteD6U0qip4zWOg/JuiplGGGotdBnNMipOCSDj7ttU2huWRIEfZRosgAPiUi/lt/Bns/Xg==}
'@futo-org/backups-orchestrator-api@1.11.5':
resolution: {integrity: sha512-YcIdWrgoyYx8L1vu2Gwtb+k1lQL32xQOK65JAAYMdZ7uEb+B/uEwHQOJWgCoxH6exwvEVtWPbvj3Mkn84r5pTQ==}
'@futo-org/backups-orchestrator-api@1.12.1':
resolution: {integrity: sha512-qTdKjndwxRmbykJswtbLjSpZ1is3/jAPgdqWeKJ4Kmnc9ZnFjn6tFb3HL4NDztCNIcaSWuLMIDJQpGWcJ3kdAA==}
peerDependencies:
'@nestjs/common': ^12.0.0
'@nestjs/core': ^12.0.0
@@ -2954,8 +2954,8 @@ packages:
'@nestjs/swagger': ^12.0.0
'@nestjs/websockets': ^12.0.0
'@futo-org/backups-orchestrator-ui@1.11.5':
resolution: {integrity: sha512-sfUfsJCeR7HEfmbLu/eAACAtY27ALxLPlZ9BYW9MnnQ9BXQHGBnM+hZ0pGs/YwtYuNsAeoq5kEbIadVN5sX6Gw==}
'@futo-org/backups-orchestrator-ui@1.12.1':
resolution: {integrity: sha512-GejBWdU3w3dnVfK5llQVn/r6vGLrcZ/0ozzBnFlM5YdiCPr9NJebFcL9WMvghxjvw+CPTfDmYQfVbm35qFAfmA==}
peerDependencies:
'@immich/ui': ^0.90.0
svelte: ^5.0.0
@@ -16341,7 +16341,7 @@ snapshots:
dependencies:
'@oazapfts/runtime': 1.2.0
'@futo-org/backups-orchestrator-api@1.11.5(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(@nestjs/schedule@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(supports-color@8.1.1)':
'@futo-org/backups-orchestrator-api@1.12.1(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@nestjs/platform-socket.io@12.1.0)(@nestjs/schedule@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0))(@nestjs/swagger@12.0.2(@nestjs/common@12.1.0(reflect-metadata@0.2.2)(rxjs@7.8.2)(supports-color@8.1.1))(@nestjs/core@12.1.0)(@typescript/typescript6@6.0.2)(reflect-metadata@0.2.2))(@nestjs/websockets@12.1.0)(reflect-metadata@0.2.2)(supports-color@8.1.1)':
dependencies:
'@futo-org/backups-api-client': 1.12.1
'@futo-org/restic-wrapper': 1.7.0
@@ -16372,7 +16372,7 @@ snapshots:
- supports-color
- utf-8-validate
'@futo-org/backups-orchestrator-ui@1.11.5(@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3))(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1))':
'@futo-org/backups-orchestrator-ui@1.12.1(@immich/ui@0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3))(@lingui/cli@6.11.0(esbuild@0.28.2)(rolldown@1.2.9)(supports-color@8.1.1))(@typescript/typescript6@6.0.2)(supports-color@8.1.1)(svelte@5.57.2(@typescript-eslint/types@8.70.1))':
dependencies:
'@futo-org/backups-api-client': 1.12.1
'@immich/ui': 0.91.1(@sveltejs/kit@3.1.0(@opentelemetry/api@1.10.0)(@sveltejs/vite-plugin-svelte@7.3.1(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(@typescript/typescript6@6.0.2)(svelte@5.57.2(@typescript-eslint/types@8.70.1))(vite@8.3.0(@types/node@24.19.2)(esbuild@0.28.2)(jiti@2.7.0)(sass@1.102.0)(terser@5.49.0)(tsx@4.23.15)(yaml@2.9.1)))(svelte@5.57.2(@typescript-eslint/types@8.70.1))(zod@4.4.3)
+1 -1
View File
@@ -119,7 +119,7 @@ ENV IMMICH_SOURCE_COMMIT=${BUILD_SOURCE_COMMIT}
ENV IMMICH_SOURCE_URL=https://github.com/immich-app/immich/commit/${BUILD_SOURCE_COMMIT}
COPY --from=ghcr.io/futo-org/backups/restic-proxy:v1.11.2@sha256:a88252d884d22a55749f45dd39a2458c6391ca3e4d3b0d9e0c2246786cf923be /usr/local/bin/restic-proxy /usr/local/bin/restic-proxy
COPY --from=ghcr.io/futo-org/restic:sha-a24cc22@sha256:7b5cdba9cc8690ecc528e239e6f8b2cfb9ef3644895cafd0e0fa9003bcded1f6 /usr/bin/restic /usr/local/bin/restic
COPY --from=ghcr.io/futo-org/restic:sha-1e904f0@sha256:501bb3e696bd78db0d89ae4e5c51b6993b9cb3f88e657020777dba49226169bc /usr/bin/restic /usr/local/bin/restic
VOLUME /data
EXPOSE 2283
+1 -1
View File
@@ -40,7 +40,7 @@
},
"dependencies": {
"@extism/extism": "2.0.0-rc13",
"@futo-org/backups-orchestrator-api": "1.11.5",
"@futo-org/backups-orchestrator-api": "1.12.1",
"@immich/plugin-sdk": "workspace:*",
"@immich/sql-tools": "^0.6.6",
"@nestjs/bullmq": "^12.0.0",
@@ -0,0 +1,69 @@
import request from 'supertest';
import { FileVaultController } from 'src/controllers/file-vault.controller.js';
import { FileVaultService } from 'src/services/file-vault.service.js';
import { factory } from 'test/small.factory.js';
import { ControllerContext, controllerSetup, mockBaseService } from 'test/utils.js';
describe(FileVaultController.name, () => {
let ctx: ControllerContext;
const service = mockBaseService(FileVaultService);
beforeAll(async () => {
ctx = await controllerSetup(FileVaultController, [{ provide: FileVaultService, useValue: service }]);
return () => ctx.close();
});
beforeEach(() => {
service.resetAllMocks();
ctx.reset();
});
it.each(['../secret', 'work/file', String.raw`work\file`, '.', '..', '\0x', 'x\ny', '', 'я'.repeat(121)])(
'rejects unsafe names: %j',
async (name) => {
const response = await request(ctx.getHttpServer()).post('/file-vault/folders').send({ name });
expect(response.status).toBe(400);
expect(service.createFolder).not.toHaveBeenCalled();
},
);
it('accepts Unicode folder names and a root destination', async () => {
const response = await request(ctx.getHttpServer())
.post('/file-vault/folders')
.send({ name: 'Рабочие чертежи', parentId: null });
expect(response.status).toBe(201);
expect(service.createFolder).toHaveBeenCalledWith(undefined, { name: 'Рабочие чертежи', parentId: null });
});
it.each(['-1', '1.5', 'no'])('rejects invalid pagination: %s', async (offset) => {
const response = await request(ctx.getHttpServer()).get('/file-vault').query({ offset });
expect(response.status).toBe(400);
expect(service.list).not.toHaveBeenCalled();
});
it('preserves an explicit root move', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).patch(`/file-vault/${id}`).send({ parentId: null });
expect(service.update).toHaveBeenCalledWith(undefined, id, { parentId: null });
});
it('does not turn a rename into a move', async () => {
const id = factory.uuid();
await request(ctx.getHttpServer()).patch(`/file-vault/${id}`).send({ name: 'Report.pdf' });
expect(service.update).toHaveBeenCalledWith(undefined, id, { name: 'Report.pdf' });
});
it('rejects a malformed download identifier', async () => {
const response = await request(ctx.getHttpServer()).get('/file-vault/invalid/download');
expect(response.status).toBe(400);
expect(service.download).not.toHaveBeenCalled();
});
it('routes restore and permanent deletion separately', async () => {
const id = factory.uuid();
const restored = await request(ctx.getHttpServer()).post(`/file-vault/${id}/restore`);
expect(restored.status).toBe(204);
expect(service.restore).toHaveBeenCalledWith(undefined, id);
const removed = await request(ctx.getHttpServer()).delete(`/file-vault/${id}/permanent`);
expect(removed.status).toBe(204);
expect(service.purge).toHaveBeenCalledWith(undefined, id);
});
});
@@ -0,0 +1,140 @@
import { Body, Controller, Delete, Get, Header, HttpCode, Param, Patch, Post, Query, Req } from '@nestjs/common';
import { ApiBody, ApiConsumes, ApiTags } from '@nestjs/swagger';
import type { Request } from 'express';
import type { AuthDto } from 'src/dtos/auth.dto.js';
import { Endpoint, HistoryBuilder } from 'src/decorators.js';
import {
FileVaultCreateFolderDto,
FileVaultEntryDto,
FileVaultFolderDto,
FileVaultListDto,
FileVaultListResponseDto,
FileVaultUpdateDto,
FileVaultUploadDto,
} from 'src/dtos/file-vault.dto.js';
import { Auth, Authenticated, FileResponse } from 'src/middleware/auth.guard.js';
import { FileVaultService } from 'src/services/file-vault.service.js';
import { UUIDParamDto } from 'src/validation.js';
@ApiTags('File Vault')
@Controller('file-vault')
export class FileVaultController {
constructor(private service: FileVaultService) {}
@Get()
@Header('Cache-Control', 'private, no-store')
@Authenticated({ permission: false })
@Endpoint({
summary: 'List personal files',
description: 'List a private folder or trash with storage usage.',
history: new HistoryBuilder().added('v3.4.0'),
})
listFiles(@Auth() auth: AuthDto, @Query() dto: FileVaultListDto): Promise<FileVaultListResponseDto> {
return this.service.list(auth, dto);
}
@Get('folders')
@Header('Cache-Control', 'private, no-store')
@Authenticated({ permission: false })
@Endpoint({
summary: 'List personal folders',
description: 'List available destination folders owned by the current user.',
history: new HistoryBuilder().added('v3.4.0'),
})
listFileFolders(@Auth() auth: AuthDto): Promise<FileVaultFolderDto[]> {
return this.service.folders(auth);
}
@Post('folders')
@Authenticated({ permission: false })
@Endpoint({
summary: 'Create a personal folder',
description: 'Create a folder without overwriting existing names.',
history: new HistoryBuilder().added('v3.4.0'),
})
createFileFolder(@Auth() auth: AuthDto, @Body() dto: FileVaultCreateFolderDto): Promise<FileVaultEntryDto> {
return this.service.createFolder(auth, dto);
}
@Post('upload')
@Authenticated({ permission: false })
@ApiConsumes('application/octet-stream')
@ApiBody({ schema: { type: 'string', format: 'binary' } })
@Endpoint({
summary: 'Upload a personal file',
description: 'Stream an original file into private storage without media processing or overwriting.',
history: new HistoryBuilder().added('v3.4.0'),
})
uploadFile(
@Auth() auth: AuthDto,
@Query() dto: FileVaultUploadDto,
@Req() request: Request,
): Promise<FileVaultEntryDto> {
return this.service.upload(auth, dto, request, request.headers['content-type'] ?? '');
}
@Get(':id/download')
@Header('Cache-Control', 'private, no-store')
@Header('X-Content-Type-Options', 'nosniff')
@Authenticated({ permission: false })
@FileResponse()
@Endpoint({
summary: 'Download a personal file',
description: 'Download original bytes as an attachment after verifying ownership.',
history: new HistoryBuilder().added('v3.4.0'),
})
downloadFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.download(auth, id);
}
@Patch(':id')
@Authenticated({ permission: false })
@Endpoint({
summary: 'Rename or move a personal item',
description: 'Rename or move a file or folder while preserving the original contents.',
history: new HistoryBuilder().added('v3.4.0'),
})
updateFile(
@Auth() auth: AuthDto,
@Param() { id }: UUIDParamDto,
@Body() dto: FileVaultUpdateDto,
): Promise<FileVaultEntryDto> {
return this.service.update(auth, id, dto);
}
@Delete(':id')
@HttpCode(204)
@Authenticated({ permission: false })
@Endpoint({
summary: 'Trash a personal item',
description: 'Move an item and its active descendants to the private trash.',
history: new HistoryBuilder().added('v3.4.0'),
})
trashFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.trash(auth, id);
}
@Post(':id/restore')
@HttpCode(204)
@Authenticated({ permission: false })
@Endpoint({
summary: 'Restore a personal item',
description: 'Restore a trashed item with its contents, or restore to the root if its parent is unavailable.',
history: new HistoryBuilder().added('v3.4.0'),
})
restoreFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.restore(auth, id);
}
@Delete(':id/permanent')
@HttpCode(204)
@Authenticated({ permission: false })
@Endpoint({
summary: 'Permanently delete a personal item',
description: 'Permanently remove a trashed item and its contents.',
history: new HistoryBuilder().added('v3.4.0'),
})
permanentlyDeleteFile(@Auth() auth: AuthDto, @Param() { id }: UUIDParamDto) {
return this.service.purge(auth, id);
}
}
+2
View File
@@ -15,6 +15,7 @@ import { DatabaseBackupController } from 'src/controllers/database-backup.contro
import { DownloadController } from 'src/controllers/download.controller.js';
import { 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,
+69
View File
@@ -0,0 +1,69 @@
import { createZodDto } from 'nestjs-zod';
import z from 'zod';
export const fileVaultName = z
.string()
.min(1)
.max(240)
.refine(
(name) =>
name.trim().length > 0 &&
!name.includes('/') &&
!name.includes('\\') &&
[...name].every((char) => {
const code = char.codePointAt(0) ?? 0;
return code >= 32 && code !== 127;
}) &&
!['.', '..'].includes(name) &&
Buffer.byteLength(name, 'utf8') <= 240,
'Invalid file name',
);
const folderId = z.uuid().nullable().optional();
const entry = z
.object({
id: z.uuid(),
parentId: z.uuid().nullable(),
name: z.string(),
isFolder: z.boolean(),
size: z.int(),
checksum: z.string().nullable(),
createdAt: z.string(),
updatedAt: z.string(),
deletedAt: z.string().nullable(),
})
.meta({ id: 'FileVaultEntryDto' });
const folder = z
.object({ id: z.uuid(), name: z.string(), parentId: z.uuid().nullable() })
.meta({ id: 'FileVaultFolderDto' });
export class FileVaultEntryDto extends createZodDto(entry) {}
export class FileVaultFolderDto extends createZodDto(folder) {}
export class FileVaultCreateFolderDto extends createZodDto(
z.object({ name: fileVaultName, parentId: folderId }).meta({ id: 'FileVaultCreateFolderDto' }),
) {}
export class FileVaultUpdateDto extends createZodDto(
z.object({ name: fileVaultName.optional(), parentId: folderId }).meta({ id: 'FileVaultUpdateDto' }),
) {}
export class FileVaultUploadDto extends createZodDto(
z.object({ name: fileVaultName, parentId: z.uuid().optional() }).meta({ id: 'FileVaultUploadDto' }),
) {}
export class FileVaultListDto extends createZodDto(
z
.object({
parentId: z.uuid().optional(),
trash: z.enum(['true', 'false']).optional(),
offset: z.coerce.number().int().min(0).default(0),
})
.meta({ id: 'FileVaultListDto' }),
) {}
export class FileVaultListResponseDto extends createZodDto(
z
.object({
entries: z.array(entry),
breadcrumbs: z.array(folder),
hasMore: z.boolean(),
usedBytes: z.int(),
maxFileBytes: z.int(),
})
.meta({ id: 'FileVaultListResponseDto' }),
) {}
+1
View File
@@ -131,6 +131,7 @@ export const AssetOrderWithRandomSchema = z
.meta({ id: 'MemorySearchOrder' });
export enum Permission {
FileVaultRead = 'fileVault.read',
All = 'all',
ActivityCreate = 'activity.create',
+16 -12
View File
@@ -511,18 +511,22 @@ limit
$4
-- AssetRepository.detectOfflineExternalAssets
update "asset"
set
"isOffline" = $1,
"deletedAt" = $2
where
"isOffline" = $3
and "isExternal" = $4
and "libraryId" = $5::uuid
and (
not "originalPath" like $6
or "originalPath" ~* $7
)
begin
declare offline_check no scroll cursor for (
select
"asset"."id",
"asset"."originalPath"
from
"asset"
where
"asset"."libraryId" = $1::uuid
and "asset"."isOffline" = $2
and "asset"."isExternal" = $3
)
fetch 10000
from
offline_check
commit
-- AssetRepository.filterNewExternalAssetPaths
select
+7
View File
@@ -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
+41 -27
View File
@@ -7,13 +7,13 @@ import {
type SelectQueryBuilder,
type Selectable,
type ShallowDehydrateObject,
UpdateResult,
type Updateable,
sql,
} from 'kysely';
import { jsonArrayFrom } from 'kysely/helpers/postgres';
import { isEmpty, isUndefined, omitBy } from 'lodash-es';
import { InjectKysely } from 'nestjs-kysely';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants.js';
import { LockableProperty, Stack } from 'src/database.js';
import { Chunked, ChunkedArray, DummyValue, GenerateSql } from 'src/decorators.js';
import { type AuthDto, isMetadataHidden } from 'src/dtos/auth.dto.js';
@@ -54,7 +54,6 @@ import {
withTagId,
withTags,
} from 'src/utils/database.js';
import { globToPostgresRegex } from 'src/utils/misc.js';
export type AssetStats = Record<AssetType, number>;
@@ -698,13 +697,14 @@ export class AssetRepository {
}
findLivePhotoMatch(options: LivePhotoSearchOptions) {
const { ownerId, otherAssetId, livePhotoCID, type } = options;
const { ownerId, libraryId, otherAssetId, livePhotoCID, type } = options;
return this.db
.selectFrom('asset')
.select(['asset.id', 'asset.ownerId'])
.innerJoin('asset_exif', 'asset.id', 'asset_exif.assetId')
.where('id', '!=', asUuid(otherAssetId))
.where('ownerId', '=', asUuid(ownerId))
.$call((qb) => (libraryId ? qb.where('libraryId', '=', asUuid(libraryId)) : qb.where('libraryId', 'is', null)))
.where('type', '=', type)
.where('asset_exif.livePhotoCID', '=', livePhotoCID)
.limit(1)
@@ -1091,31 +1091,45 @@ export class AssetRepository {
.execute();
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING], [DummyValue.STRING]] })
async detectOfflineExternalAssets(
libraryId: string,
importPaths: string[],
exclusionPatterns: string[],
): Promise<UpdateResult> {
const paths = importPaths.map((importPath) => `${importPath}%`);
const exclusions = exclusionPatterns.map((pattern) => globToPostgresRegex(pattern));
@GenerateSql({ params: [DummyValue.UUID, () => true] })
detectOfflineExternalAssets(libraryId: string, isInLibrary: (originalPath: string) => boolean): Promise<number> {
// read and write on one connection, so concurrent library scans can't deadlock on the pool
return this.db.transaction().execute(async (trx) => {
const query = trx
.selectFrom('asset')
.select(['asset.id', 'asset.originalPath'])
.where('asset.libraryId', '=', asUuid(libraryId))
.where('asset.isOffline', '=', false)
.where('asset.isExternal', '=', true);
await sql`declare offline_check no scroll cursor for ${query}`.execute(trx);
return this.db
.updateTable('asset')
.set({
isOffline: true,
deletedAt: new Date(),
})
.where('isOffline', '=', false)
.where('isExternal', '=', true)
.where('libraryId', '=', asUuid(libraryId))
.where((eb) =>
eb.or([
eb.not(eb.or(paths.map((path) => eb('originalPath', 'like', path)))),
eb.or(exclusions.map((pattern) => eb('originalPath', '~*', pattern))),
]),
)
.executeTakeFirstOrThrow();
const fetchBatch = async () => {
const { rows } = await sql<{ id: string; originalPath: string }>`
fetch ${sql.lit(JOBS_LIBRARY_PAGINATION_SIZE)} from offline_check
`.execute(trx);
return rows;
};
const deletedAt = new Date();
let count = 0;
for (let rows = await fetchBatch(); rows.length > 0; rows = await fetchBatch()) {
const ids = rows.filter(({ originalPath }) => !isInLibrary(originalPath)).map(({ id }) => id);
if (ids.length === 0) {
continue;
}
// a concurrent check of the same library may have offlined these rows after the cursor was declared
const { numUpdatedRows } = await trx
.updateTable('asset')
.set({ isOffline: true, deletedAt })
.where('id', '=', anyUuid(ids))
.where('isOffline', '=', false)
.executeTakeFirst();
count += Number(numUpdatedRows);
}
return count;
});
}
@GenerateSql({ params: [DummyValue.UUID, [DummyValue.STRING]] })
@@ -0,0 +1,401 @@
import { Injectable } from '@nestjs/common';
import { ZipArchive } from 'archiver';
import { type Insertable, type Kysely, type Selectable, type Transaction, sql } from 'kysely';
import { InjectKysely } from 'nestjs-kysely';
import { createHash, randomUUID } from 'node:crypto';
import { createReadStream, createWriteStream } from 'node:fs';
import { mkdir, open, rename, rm, statfs } from 'node:fs/promises';
import { dirname, join } from 'node:path';
import { Readable, Transform } from 'node:stream';
import { pipeline } from 'node:stream/promises';
import { StorageCore } from 'src/cores/storage.core.js';
import { FileVaultListDto, FileVaultUpdateDto } from 'src/dtos/file-vault.dto.js';
import { DB } from 'src/schema/index.js';
import { FileVaultTable } from 'src/schema/tables/file-vault.table.js';
export const FILE_VAULT_MAX_BYTES = 20 * 1024 ** 3;
export type VaultEntry = Selectable<FileVaultTable>;
export class FileVaultFailure extends Error {
constructor(
public code: 'missing' | 'conflict' | 'invalid' | 'quota' | 'space',
message: string,
) {
super(message);
}
}
@Injectable()
export class FileVaultRepository {
constructor(@InjectKysely() private db: Kysely<DB>) {}
private path(ownerId: string, id: string) {
return join(StorageCore.getMediaLocation(), 'files', ownerId, id);
}
private async locked<T>(ownerId: string, handler: (tx: Transaction<DB>) => Promise<T>) {
return this.db.transaction().execute(async (tx) => {
await tx.selectFrom('user').select('id').where('id', '=', ownerId).forUpdate().executeTakeFirstOrThrow();
return handler(tx);
});
}
private async get(db: Kysely<DB>, ownerId: string, id: string) {
const entry = await db
.selectFrom('file_vault')
.selectAll()
.where('ownerId', '=', ownerId)
.where('id', '=', id)
.executeTakeFirst();
if (!entry) {
throw new FileVaultFailure('missing', 'File or folder not found');
}
return entry;
}
private async ancestors(db: Kysely<DB>, ownerId: string, id: string | null, excluded?: string) {
const entries: VaultEntry[] = [];
const seen = new Set<string>();
const excludedId = excluded?.toLowerCase();
while (id) {
const entry = await this.get(db, ownerId, id);
if (seen.has(entry.id) || entry.id === excludedId || entries.length >= 128) {
throw new FileVaultFailure('invalid', 'Cannot move a folder into itself or its descendants');
}
seen.add(entry.id);
if (!entry.isFolder || entry.deletedAt) {
throw new FileVaultFailure('missing', 'Destination folder is unavailable');
}
entries.unshift(entry);
id = entry.parentId;
}
return entries;
}
private async available(db: Kysely<DB>, ownerId: string, parentId: string | null, name: string, except?: string) {
const duplicate = await db
.selectFrom('file_vault')
.select('id')
.where('ownerId', '=', ownerId)
.where('parentId', parentId ? '=' : 'is', parentId)
.where('deletedAt', 'is', null)
.where(sql<string>`lower(name)`, '=', name.toLowerCase())
.$if(!!except, (q) => q.where('id', '!=', except!))
.executeTakeFirst();
if (duplicate) {
throw new FileVaultFailure('conflict', 'A file or folder with this name already exists');
}
}
async list(ownerId: string, dto: FileVaultListDto) {
return this.locked(ownerId, async (tx) => {
const breadcrumbs = dto.trash === 'true' ? [] : await this.ancestors(tx, ownerId, dto.parentId ?? null);
let query = tx.selectFrom('file_vault').selectAll().where('ownerId', '=', ownerId);
query =
dto.trash === 'true'
? query.where('deletedAt', 'is not', null).whereRef('trashRootId', '=', 'id')
: query.where('deletedAt', 'is', null).where('parentId', dto.parentId ? '=' : 'is', dto.parentId ?? null);
const entries = await query
.orderBy('isFolder', 'desc')
.orderBy('name')
.orderBy('id')
.offset(dto.offset)
.limit(101)
.execute();
const usage = await tx
.selectFrom('file_vault')
.select((eb) => eb.fn.sum<number>('size').as('size'))
.where('ownerId', '=', ownerId)
.executeTakeFirst();
return {
entries: entries.slice(0, 100),
breadcrumbs,
hasMore: entries.length > 100,
usedBytes: Number(usage?.size ?? 0),
maxFileBytes: FILE_VAULT_MAX_BYTES,
};
});
}
async folders(ownerId: string) {
const result = await sql<VaultEntry>`WITH RECURSIVE folders AS (
SELECT * FROM file_vault WHERE "ownerId"=${ownerId} AND "parentId" IS NULL AND "isFolder" AND "deletedAt" IS NULL
UNION ALL SELECT f.* FROM file_vault f JOIN folders p ON f."parentId"=p.id
WHERE f."ownerId"=${ownerId} AND f."isFolder" AND f."deletedAt" IS NULL
) SELECT * FROM folders ORDER BY name`.execute(this.db);
return result.rows;
}
async createFolder(ownerId: string, name: string, parentId: string | null) {
return this.locked(ownerId, async (tx) => {
const parents = await this.ancestors(tx, ownerId, parentId);
if (parents.length >= 128) {
throw new FileVaultFailure('invalid', 'Folder nesting limit exceeded');
}
await this.available(tx, ownerId, parentId, name);
return tx
.insertInto('file_vault')
.values({ ownerId, parentId, name, isFolder: true, checksum: null, deletedAt: null, trashRootId: null })
.returningAll()
.executeTakeFirstOrThrow();
});
}
async upload(ownerId: string, name: string, parentId: string | null, source: Readable) {
await this.ancestors(this.db, ownerId, parentId);
await this.available(this.db, ownerId, parentId, name);
const id = randomUUID();
const path = this.path(ownerId, id);
const temporary = path + '.part';
await mkdir(join(StorageCore.getMediaLocation(), 'files', ownerId), { recursive: true, mode: 0o700 });
const disk = await statfs(StorageCore.getMediaLocation());
const budget = Math.min(FILE_VAULT_MAX_BYTES, disk.bavail * disk.bsize - 100 * 1024 ** 2);
let size = 0;
let stored = false;
const hash = createHash('sha256');
try {
await pipeline(
source,
new Transform({
transform(chunk: Buffer, _, callback) {
size += chunk.length;
if (size > budget) {
callback(new FileVaultFailure('space', 'File exceeds the upload limit or available disk space'));
return;
}
hash.update(chunk);
callback(null, chunk);
},
}),
createWriteStream(temporary, { flags: 'wx', mode: 0o600 }),
);
const handle = await open(temporary, 'r+');
try {
await handle.sync();
} finally {
await handle.close();
}
const checksum = hash.digest('hex');
return await this.locked(ownerId, async (tx) => {
await this.ancestors(tx, ownerId, parentId);
await this.available(tx, ownerId, parentId, name);
const user = await tx
.selectFrom('user')
.select(['quotaUsageInBytes', 'quotaSizeInBytes'])
.where('id', '=', ownerId)
.executeTakeFirstOrThrow();
if (user.quotaSizeInBytes !== null && Number(user.quotaUsageInBytes) + size > Number(user.quotaSizeInBytes)) {
throw new FileVaultFailure('quota', 'Storage quota exceeded');
}
await rename(temporary, path);
stored = true;
if (process.platform !== 'win32') {
const directory = await open(dirname(path), 'r');
try {
await directory.sync();
} finally {
await directory.close();
}
}
const entry: Insertable<FileVaultTable> = {
id,
ownerId,
parentId,
name,
isFolder: false,
size,
checksum,
deletedAt: null,
trashRootId: null,
};
const saved = await tx.insertInto('file_vault').values(entry).returningAll().executeTakeFirstOrThrow();
await tx
.updateTable('user')
.set({ quotaUsageInBytes: sql`"quotaUsageInBytes" + ${size}` })
.where('id', '=', ownerId)
.execute();
return saved;
});
} catch (error) {
await rm(temporary, { force: true });
if (stored) {
let verified = false;
const committed = await this.db
.selectFrom('file_vault')
.selectAll()
.where('ownerId', '=', ownerId)
.where('id', '=', id)
.executeTakeFirst()
.then((row) => {
verified = true;
return row;
})
.catch(() => {});
if (committed) {
return committed;
}
if (verified) {
await rm(path, { force: true });
}
}
throw error;
}
}
async download(ownerId: string, id: string) {
const entry = await this.get(this.db, ownerId, id);
if (entry.deletedAt) {
throw new FileVaultFailure('missing', 'File not found');
}
await this.ancestors(this.db, ownerId, entry.parentId);
if (entry.isFolder) {
const tree = await sql<VaultEntry & { path: string[] }>`WITH RECURSIVE tree AS (
SELECT f.*, ARRAY[f.name]::text[] AS path FROM file_vault f WHERE f.id=${id} AND f."ownerId"=${ownerId} AND f."deletedAt" IS NULL
UNION ALL SELECT f.*, t.path || f.name::text FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId} AND f."deletedAt" IS NULL
) SELECT * FROM tree`.execute(this.db);
const archive = new ZipArchive({ store: true });
archive.on('warning', (error: Error) => archive.destroy(error));
for (const item of tree.rows) {
const name = item.path.join('/');
if (item.isFolder) {
archive.append('', { name: `${name}/`, mode: 0o755 });
} else {
archive.file(this.path(ownerId, item.id), { name, mode: 0o644 });
}
}
setImmediate(() => {
void archive.finalize().catch((error: Error) => archive.destroy(error));
});
return { entry, stream: archive };
}
return { entry, stream: createReadStream(this.path(ownerId, entry.id)) };
}
removeUserFiles(ownerId: string) {
return rm(join(StorageCore.getMediaLocation(), 'files', ownerId), { recursive: true, force: true });
}
async update(ownerId: string, id: string, dto: FileVaultUpdateDto) {
return this.locked(ownerId, async (tx) => {
const entry = await this.get(tx, ownerId, id);
const parentId = dto.parentId === undefined ? entry.parentId : dto.parentId;
if (entry.deletedAt && dto.parentId !== undefined) {
throw new FileVaultFailure('invalid', 'Restore the item before moving it');
}
if (!entry.deletedAt) {
const parents = await this.ancestors(tx, ownerId, parentId, entry.isFolder ? entry.id : undefined);
if (entry.isFolder && dto.parentId !== undefined) {
const height = await sql<{ depth: number }>`WITH RECURSIVE tree AS (
SELECT id, 1 AS depth FROM file_vault WHERE id=${entry.id} AND "ownerId"=${ownerId}
UNION ALL SELECT f.id, t.depth+1 FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId}
) SELECT max(depth)::int AS depth FROM tree`.execute(tx);
if (parents.length + height.rows[0].depth > 128) {
throw new FileVaultFailure('invalid', 'Folder nesting limit exceeded');
}
}
await this.available(tx, ownerId, parentId, dto.name ?? entry.name, id);
}
return tx
.updateTable('file_vault')
.set({ name: dto.name ?? entry.name, parentId })
.where('id', '=', id)
.where('ownerId', '=', ownerId)
.returningAll()
.executeTakeFirstOrThrow();
});
}
async trash(ownerId: string, id: string) {
return this.locked(ownerId, async (tx) => {
const entry = await this.get(tx, ownerId, id);
if (entry.deletedAt) {
throw new FileVaultFailure('invalid', 'Item is already in the trash');
}
await this.ancestors(tx, ownerId, entry.parentId);
await sql`WITH RECURSIVE tree AS (
SELECT id FROM file_vault WHERE id=${id} AND "ownerId"=${ownerId}
UNION ALL SELECT f.id FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId}
) UPDATE file_vault SET "deletedAt"=now(), "trashRootId"=${id}::uuid WHERE id IN (SELECT id FROM tree) AND "deletedAt" IS NULL`.execute(
tx,
);
});
}
async restore(ownerId: string, id: string) {
return this.locked(ownerId, async (tx) => {
const entry = await this.get(tx, ownerId, id);
if (!entry.deletedAt || entry.trashRootId !== entry.id) {
throw new FileVaultFailure('invalid', 'Select a root item in the trash');
}
let parentId = entry.parentId;
try {
await this.ancestors(tx, ownerId, parentId);
} catch (error) {
if (!(error instanceof FileVaultFailure)) {
throw error;
}
parentId = null;
}
await this.available(tx, ownerId, parentId, entry.name);
await tx
.updateTable('file_vault')
.set({ parentId })
.where('id', '=', id)
.where('ownerId', '=', ownerId)
.execute();
await tx
.updateTable('file_vault')
.set({ deletedAt: null, trashRootId: null })
.where('ownerId', '=', ownerId)
.where('trashRootId', '=', id)
.execute();
});
}
async purge(ownerId: string, id: string) {
await this.locked(ownerId, async (tx) => {
const entry = await this.get(tx, ownerId, id);
if (!entry.deletedAt || entry.trashRootId !== entry.id) {
throw new FileVaultFailure('invalid', 'Only items in the trash can be permanently deleted');
}
const tree = await sql<VaultEntry>`WITH RECURSIVE tree AS (
SELECT * FROM file_vault WHERE id=${id} AND "ownerId"=${ownerId}
UNION ALL SELECT f.* FROM file_vault f JOIN tree t ON f."parentId"=t.id WHERE f."ownerId"=${ownerId}
) SELECT * FROM tree`.execute(tx);
const files = tree.rows.filter((file) => !file.isFolder).map((file) => ({ id: file.id, ownerId }));
if (files.length > 0) {
await tx
.insertInto('file_vault_delete')
.values(files)
.onConflict((oc) => oc.column('id').doNothing())
.execute();
}
const size = tree.rows.reduce((total, row) => total + Number(row.size), 0);
await tx.deleteFrom('file_vault').where('ownerId', '=', ownerId).where('id', '=', id).execute();
await tx
.updateTable('user')
.set({ quotaUsageInBytes: sql`GREATEST(0, "quotaUsageInBytes" - ${size})` })
.where('id', '=', ownerId)
.execute();
});
return this.flushDeletes(ownerId);
}
async flushDeletes(ownerId?: string) {
const entries = await this.db
.selectFrom('file_vault_delete')
.selectAll()
.$if(!!ownerId, (q) => q.where('ownerId', '=', ownerId!))
.limit(1000)
.execute();
const failed: string[] = [];
for (const entry of entries) {
try {
await rm(this.path(entry.ownerId, entry.id), { force: true });
await this.db.deleteFrom('file_vault_delete').where('id', '=', entry.id).execute();
} catch {
failed.push(entry.id);
}
}
return failed;
}
}
+2
View File
@@ -18,6 +18,7 @@ import { DownloadRepository } from 'src/repositories/download.repository.js';
import { DuplicateRepository } from 'src/repositories/duplicate.repository.js';
import { 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,
+13 -6
View File
@@ -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)
+5
View File
@@ -54,6 +54,7 @@ import { AuthChallengeTable } from 'src/schema/tables/auth-challenge.table.js';
import { ClusterGroupRequestTable } from 'src/schema/tables/cluster-group-request.table.js';
import { 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);
}
+1
View File
@@ -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>;
}
+4
View File
@@ -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,
@@ -241,11 +241,19 @@ describe(DatabaseBackupService.name, () => {
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('gzip', ['--rsyncable']);
});
it('should compress with pigz on every available core when it is installed', async () => {
it.each([
{ cores: 1, processes: '1' },
{ cores: 8, processes: '6' },
{ cores: 72, processes: '32' },
])('should compress with pigz on $processes of $cores cores when it is installed', async ({ cores, processes }) => {
mocks.process.isCommandAvailable.mockResolvedValue(true);
mocks.process.availableParallelism.mockReturnValue(8);
mocks.process.availableParallelism.mockReturnValue(cores);
await sut.handleBackupDatabase();
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('pigz', ['--rsyncable', '--processes', '8']);
expect(mocks.process.spawnDuplexStream).toHaveBeenLastCalledWith('pigz', [
'--rsyncable',
'--processes',
processes,
]);
});
it('should fail if pg_dump fails', async () => {
@@ -271,7 +271,7 @@ export class DatabaseBackupService {
private async getCompressionCommand() {
if (await this.processRepository.isCommandAvailable('pigz')) {
const processes = this.processRepository.availableParallelism();
const processes = Math.min(Math.max(Math.floor(this.processRepository.availableParallelism() * 0.75), 1), 32);
return { command: 'pigz', args: ['--rsyncable', '--processes', processes.toString()] };
}
@@ -0,0 +1,44 @@
import {
ConflictException,
ForbiddenException,
NotFoundException,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { Readable } from 'node:stream';
import { AuthDto } from 'src/dtos/auth.dto.js';
import { FileVaultFailure } from 'src/repositories/file-vault.repository.js';
import { FileVaultService } from 'src/services/file-vault.service.js';
import { factory } from 'test/small.factory.js';
import { newTestService } from 'test/utils.js';
describe(FileVaultService.name, () => {
it('rejects a parsed JSON body instead of storing an empty file', async () => {
const { sut, mocks } = newTestService(FileVaultService);
await expect(
sut.upload(factory.auth(), { name: 'report.json' }, Readable.from('{}'), 'application/json'),
).rejects.toBeInstanceOf(UnsupportedMediaTypeException);
expect(mocks.fileVault.upload).not.toHaveBeenCalled();
});
it.each(['apiKey', 'sharedLink'] as const)('rejects %s before accepting uploaded bytes', async (kind) => {
const { sut, mocks } = newTestService(FileVaultService);
const auth = { ...factory.auth(), [kind]: {} } as AuthDto;
await expect(sut.upload(auth, { name: 'private.pdf' }, Readable.from('private'))).rejects.toBeInstanceOf(
ForbiddenException,
);
expect(mocks.fileVault.upload).not.toHaveBeenCalled();
});
it('does not expose another account through lookup failures', async () => {
const { sut, mocks } = newTestService(FileVaultService);
const auth = factory.auth();
mocks.fileVault.download.mockRejectedValue(new FileVaultFailure('missing', 'File not found'));
await expect(sut.download(auth, factory.uuid())).rejects.toBeInstanceOf(NotFoundException);
expect(mocks.fileVault.download).toHaveBeenCalledWith(auth.user.id, expect.any(String));
});
it('returns a conflict without overwriting a colliding name', async () => {
const { sut, mocks } = newTestService(FileVaultService);
mocks.fileVault.restore.mockRejectedValue(new FileVaultFailure('conflict', 'Name already exists'));
await expect(sut.restore(factory.auth(), factory.uuid())).rejects.toBeInstanceOf(ConflictException);
});
});
+156
View File
@@ -0,0 +1,156 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
HttpException,
HttpStatus,
Injectable,
NotFoundException,
StreamableFile,
UnsupportedMediaTypeException,
} from '@nestjs/common';
import { Readable } from 'node:stream';
import { OnEvent } from 'src/decorators.js';
import { AuthDto } from 'src/dtos/auth.dto.js';
import {
FileVaultCreateFolderDto,
FileVaultEntryDto,
FileVaultFolderDto,
FileVaultListDto,
FileVaultListResponseDto,
FileVaultUpdateDto,
FileVaultUploadDto,
} from 'src/dtos/file-vault.dto.js';
import { ImmichWorker, Permission } from 'src/enum.js';
import { FileVaultFailure, VaultEntry } from 'src/repositories/file-vault.repository.js';
import { BaseService } from 'src/services/base.service.js';
import { handlePromiseError } from 'src/utils/misc.js';
const mapEntry = (entry: VaultEntry): FileVaultEntryDto => ({
id: entry.id,
parentId: entry.parentId,
name: entry.name,
isFolder: entry.isFolder,
size: Number(entry.size),
checksum: entry.checksum,
createdAt: new Date(entry.createdAt).toISOString(),
updatedAt: new Date(entry.updatedAt).toISOString(),
deletedAt: entry.deletedAt ? new Date(entry.deletedAt).toISOString() : null,
});
const mapFolder = (entry: VaultEntry): FileVaultFolderDto => ({
id: entry.id,
name: entry.name,
parentId: entry.parentId,
});
@Injectable()
export class FileVaultService extends BaseService {
@OnEvent({ name: 'AppBootstrap', workers: [ImmichWorker.Microservices] })
async onBootstrap() {
await this.flushDeletes();
this.cronRepository.create({
name: 'file-vault-cleanup',
expression: '17 * * * *',
onTick: () => handlePromiseError(this.flushDeletes(), this.logger),
});
}
private async flushDeletes() {
const failed = await this.fileVaultRepository.flushDeletes();
if (failed.length > 0) {
this.logger.warn(`File storage cleanup will retry ${failed.length} deleted files`);
}
}
@OnEvent({ name: 'UserDelete' })
handleUserDelete(user: { id: string }) {
return this.fileVaultRepository.removeUserFiles(user.id);
}
private async run<T>(auth: AuthDto, handler: () => Promise<T>) {
if (auth.apiKey || auth.sharedLink) {
throw new ForbiddenException('Personal file storage requires a user session');
}
await this.requireAccess({ auth, permission: Permission.FileVaultRead, ids: [auth.user.id] });
try {
return await handler();
} catch (error) {
if (!(error instanceof FileVaultFailure)) {
throw error;
}
if (error.code === 'missing') {
throw new NotFoundException(error.message);
}
if (error.code === 'conflict') {
throw new ConflictException(error.message);
}
if (error.code === 'quota' || error.code === 'space') {
throw new HttpException(error.message, HttpStatus.INSUFFICIENT_STORAGE);
}
throw new BadRequestException(error.message);
}
}
list(auth: AuthDto, dto: FileVaultListDto): Promise<FileVaultListResponseDto> {
return this.run(auth, async () => {
const result = await this.fileVaultRepository.list(auth.user.id, dto);
return {
...result,
entries: result.entries.map((entry) => mapEntry(entry)),
breadcrumbs: result.breadcrumbs.map((entry) => mapFolder(entry)),
};
});
}
folders(auth: AuthDto): Promise<FileVaultFolderDto[]> {
return this.run(auth, async () => {
const folders = await this.fileVaultRepository.folders(auth.user.id);
return folders.map((entry) => mapFolder(entry));
});
}
createFolder(auth: AuthDto, dto: FileVaultCreateFolderDto): Promise<FileVaultEntryDto> {
return this.run(auth, async () =>
mapEntry(await this.fileVaultRepository.createFolder(auth.user.id, dto.name, dto.parentId ?? null)),
);
}
upload(
auth: AuthDto,
dto: FileVaultUploadDto,
stream: Readable,
contentType = 'application/octet-stream',
): Promise<FileVaultEntryDto> {
return this.run(auth, async () => {
if (contentType.split(';', 1)[0].trim().toLowerCase() !== 'application/octet-stream') {
throw new UnsupportedMediaTypeException('Upload raw file bytes as application/octet-stream');
}
return mapEntry(await this.fileVaultRepository.upload(auth.user.id, dto.name, dto.parentId ?? null, stream));
});
}
update(auth: AuthDto, id: string, dto: FileVaultUpdateDto): Promise<FileVaultEntryDto> {
return this.run(auth, async () => mapEntry(await this.fileVaultRepository.update(auth.user.id, id, dto)));
}
trash(auth: AuthDto, id: string) {
return this.run(auth, () => this.fileVaultRepository.trash(auth.user.id, id));
}
restore(auth: AuthDto, id: string) {
return this.run(auth, () => this.fileVaultRepository.restore(auth.user.id, id));
}
purge(auth: AuthDto, id: string) {
return this.run(auth, async () => {
const failed = await this.fileVaultRepository.purge(auth.user.id, id);
if (failed.length > 0) {
this.logger.warn(`File storage cleanup will retry ${failed.length} deleted files`);
}
});
}
download(auth: AuthDto, id: string): Promise<StreamableFile> {
return this.run(auth, async () => {
const { entry, stream } = await this.fileVaultRepository.download(auth.user.id, id);
return new StreamableFile(stream, {
type: entry.isFolder ? 'application/zip' : 'application/octet-stream',
length: entry.isFolder ? undefined : Number(entry.size),
disposition: `attachment; filename*=UTF-8''${encodeURIComponent(entry.name + (entry.isFolder ? '.zip' : ''))}`,
});
});
}
}
+1 -1
View File
@@ -32,7 +32,7 @@ export class FutoBackupsService {
this.orchestratorService.setImmichIntegration({
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 })),
+2
View File
@@ -13,6 +13,7 @@ import { DatabaseBackupService } from 'src/services/database-backup.service.js';
import { DatabaseService } from 'src/services/database.service.js';
import { 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,
+25 -20
View File
@@ -273,31 +273,42 @@ describe(LibraryService.name, () => {
});
describe('handleQueueSyncAssets', () => {
it('should call the offline check', async () => {
const library = factory.library();
it('should offline assets outside the import paths or covered by an exclusion pattern', async () => {
const library = factory.library({ importPaths: ['/import/'], exclusionPatterns: ['**/raw/**'] });
const asset = AssetFactory.create({ libraryId: library.id, originalPath: '/import/photo.jpg' });
mocks.library.get.mockResolvedValue(library);
mocks.asset.getLibraryAssetCount.mockResolvedValue(3);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue(2);
mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success);
expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith(library.id, expect.any(Function));
const [, isInLibrary] = mocks.asset.detectOfflineExternalAssets.mock.calls[0];
expect(isInLibrary('/import/photo.jpg')).toBe(true);
expect(isInLibrary('/elsewhere/photo.jpg')).toBe(false);
expect(isInLibrary('/import/RAW/photo.jpg')).toBe(false);
});
it('should not queue asset sync when every asset was offlined', async () => {
const library = factory.library({ importPaths: ['/import/'] });
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
mocks.asset.detectOfflineExternalAssets.mockResolvedValue(1);
const response = await sut.handleQueueSyncAssets({ id: library.id });
await expect(sut.handleQueueSyncAssets({ id: library.id })).resolves.toBe(JobStatus.Success);
expect(response).toBe(JobStatus.Success);
expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith(
library.id,
library.importPaths,
library.exclusionPatterns,
);
expect(mocks.library.streamAssetIds).not.toHaveBeenCalled();
expect(mocks.job.queue).not.toHaveBeenCalled();
});
it('should skip an empty library', async () => {
const library = factory.library();
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.getLibraryAssetCount.mockResolvedValue(0);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 1n });
const response = await sut.handleQueueSyncAssets({ id: library.id });
@@ -310,10 +321,9 @@ describe(LibraryService.name, () => {
const asset = AssetFactory.create({ libraryId: library.id, isExternal: true });
mocks.library.get.mockResolvedValue(library);
mocks.storage.walk.mockImplementation(async function* generator() {});
mocks.asset.detectOfflineExternalAssets.mockResolvedValue(0);
mocks.library.streamAssetIds.mockReturnValue(makeStream([asset]));
mocks.asset.getLibraryAssetCount.mockResolvedValue(1);
mocks.asset.detectOfflineExternalAssets.mockResolvedValue({ numUpdatedRows: 0n });
const response = await sut.handleQueueSyncAssets({ id: library.id });
@@ -330,11 +340,6 @@ describe(LibraryService.name, () => {
});
expect(response).toBe(JobStatus.Success);
expect(mocks.asset.detectOfflineExternalAssets).toHaveBeenCalledWith(
library.id,
library.importPaths,
library.exclusionPatterns,
);
});
it("should fail if library can't be found", async () => {
+11 -27
View File
@@ -34,7 +34,7 @@ import { AssetSyncResult } from 'src/repositories/library.repository.js';
import { AssetTable } from 'src/schema/tables/asset.table.js';
import { BaseService } from 'src/services/base.service.js';
import { mimeTypes } from 'src/utils/mime-types.js';
import { batched, findOrFail, handlePromiseError } from 'src/utils/misc.js';
import { batched, createLibraryMatcher, findOrFail, handlePromiseError } from 'src/utils/misc.js';
@Injectable()
export class LibraryService extends BaseService {
@@ -483,6 +483,7 @@ export class LibraryService extends BaseService {
@OnJob({ name: JobName.LibrarySyncAssets, queue: QueueName.Library })
async handleSyncAssets(job: JobOf<JobName.LibrarySyncAssets>): Promise<JobStatus> {
const assets = await this.assetJobRepository.getForSyncAssets(job.assetIds);
const isInLibrary = createLibraryMatcher(job);
const assetIdsToOffline: string[] = [];
const trashedAssetIdsToOffline: string[] = [];
@@ -517,33 +518,19 @@ export class LibraryService extends BaseService {
break;
}
case AssetSyncResult.CHECK_OFFLINE: {
const isInImportPath = job.importPaths.some((path) => asset.originalPath.startsWith(path));
if (!isInImportPath) {
if (!isInLibrary(asset.originalPath)) {
this.logger.verbose(
`Offline asset ${asset.originalPath} is still not in any import path, keeping offline in library ${job.libraryId}`,
`Offline asset ${asset.originalPath} is still not in any import path or covered by an exclusion pattern, keeping offline in library ${job.libraryId}`,
);
break;
}
const isExcluded = job.exclusionPatterns.some((pattern) =>
picomatch.isMatch(asset.originalPath, pattern, { nocase: true }),
);
if (!isExcluded) {
this.logger.debug(`Offline asset ${asset.originalPath} is now online in library ${job.libraryId}`);
if (asset.status === AssetStatus.Trashed) {
trashedAssetIdsToOnline.push(asset.id);
} else {
assetIdsToOnline.push(asset.id);
}
break;
this.logger.debug(`Offline asset ${asset.originalPath} is now online in library ${job.libraryId}`);
if (asset.status === AssetStatus.Trashed) {
trashedAssetIdsToOnline.push(asset.id);
} else {
assetIdsToOnline.push(asset.id);
}
this.logger.verbose(
`Offline asset ${asset.originalPath} is in an import path but still covered by exclusion pattern, keeping offline in library ${job.libraryId}`,
);
break;
}
}
@@ -721,14 +708,11 @@ export class LibraryService extends BaseService {
`Checking ${assetCount} asset(s) against import paths and exclusion patterns in library ${library.id}...`,
);
const offlineResult = await this.assetRepository.detectOfflineExternalAssets(
const affectedAssetCount = await this.assetRepository.detectOfflineExternalAssets(
library.id,
library.importPaths,
library.exclusionPatterns,
createLibraryMatcher(library),
);
const affectedAssetCount = Number(offlineResult.numUpdatedRows);
this.logger.log(
`${affectedAssetCount} asset(s) out of ${assetCount} were offlined due to import paths and/or exclusion pattern(s) in library ${library.id}`,
);
+53 -2
View File
@@ -3099,7 +3099,7 @@ describe(MediaService.name, () => {
'-v',
'verbose',
'-vf',
'hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq:format=nv12',
'format=nv12,hwupload=extra_hw_frames=64,scale_qsv=-1:720:mode=hq:format=nv12',
'-preset',
'7',
'-global_quality:v',
@@ -3116,6 +3116,24 @@ describe(MediaService.name, () => {
);
});
it('should tone-map to nv12 before upload for sw decode qsv', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Qsv, accelDecode: false },
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
outputOptions: expect.arrayContaining([
'-vf',
'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=nv12,hwupload=extra_hw_frames=64',
]),
}),
);
});
it('should set options for qsv with custom dri node with sw decode', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({
@@ -3347,7 +3365,7 @@ describe(MediaService.name, () => {
'-v',
'verbose',
'-vf',
'hwupload=extra_hw_frames=64,scale_vaapi=-2:720:mode=hq:out_range=pc:format=nv12',
'format=nv12,hwupload=extra_hw_frames=64,scale_vaapi=-2:720:mode=hq:out_range=pc:format=nv12',
'-compression_level',
'7',
'-rc_mode',
@@ -3358,6 +3376,39 @@ describe(MediaService.name, () => {
);
});
it('should convert to nv12 before upload for sw decode vaapi without scaling', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: false, targetResolution: 'original' },
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
outputOptions: expect.arrayContaining(['-vf', 'format=nv12,hwupload=extra_hw_frames=64']),
}),
);
});
it('should tone-map to nv12 before upload for sw decode vaapi', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.videoStreamHDR });
mocks.systemMetadata.get.mockResolvedValue({
ffmpeg: { accel: TranscodeHardwareAcceleration.Vaapi, accelDecode: false },
});
await sut.handleVideoConversion({ id: 'video-id' });
expect(mocks.media.transcode).toHaveBeenCalledWith(
'/original/path.ext',
expect.any(String),
expect.objectContaining({
outputOptions: expect.arrayContaining([
'-vf',
'tonemapx=tonemap=hable:desat=0:p=bt709:t=bt709:m=bt709:r=pc:peak=100:format=nv12,hwupload=extra_hw_frames=64',
]),
}),
);
});
it('should set vbr options for vaapi when max bitrate is enabled', async () => {
mocks.assetJob.getForVideoConversion.mockResolvedValue({ ...asset, ...probeStub.matroskaContainer });
mocks.systemMetadata.get.mockResolvedValue({
+43 -21
View File
@@ -2,6 +2,7 @@ import { BinaryField, ExifDateTime } from 'exiftool-vendored';
import { DateTime } from 'luxon';
import { randomBytes } from 'node:crypto';
import { Stats } from 'node:fs';
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core.js';
import { defaults } from 'src/dtos/config.dto.js';
import {
AssetFileType,
@@ -13,6 +14,7 @@ import {
JobName,
JobStatus,
SourceType,
StorageFolder,
} from 'src/enum.js';
import { ImmichTags } from 'src/repositories/metadata.repository.js';
import { MetadataService, firstDateTime } from 'src/services/metadata.service.js';
@@ -21,7 +23,7 @@ import { PersonGroupFactory } from 'test/factories/person-group.factory.js';
import { PersonFactory } from 'test/factories/person.factory.js';
import { videoInfoStub } from 'test/fixtures/media.stub.js';
import { tagStub } from 'test/fixtures/tag.stub.js';
import { getForMetadataExtraction, getForSidecarWrite } from 'test/mappers.js';
import { getForAsset, getForMetadataExtraction, getForSidecarWrite } from 'test/mappers.js';
import { factory } from 'test/small.factory.js';
import { ServiceMocks, makeStream, newTestService } from 'test/utils.js';
@@ -953,27 +955,47 @@ describe(MetadataService.name, () => {
});
});
it('should delete old motion photo video assets if they do not match what is extracted', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mockReadTags({
Directory: 'foo/bar/',
MotionPhoto: 1,
MicroVideo: 1,
MicroVideoOffset: 1,
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(AssetFactory.create({ type: AssetType.Video }));
const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video);
it.each([
{
motion: 'an extracted motion video',
getOriginalPath: (motion: ThumbnailPathEntity) => StorageCore.getAndroidMotionPath(motion, motion.id),
deleteOnDisk: true,
},
{
motion: 'an uploaded motion video',
getOriginalPath: (motion: ThumbnailPathEntity) =>
StorageCore.getNestedPath(StorageFolder.Upload, motion.ownerId, `${motion.id}.mov`),
deleteOnDisk: false,
},
{ motion: 'an external motion video', getOriginalPath: () => '/mnt/photos/IMG_0001.mov', deleteOnDisk: false },
{ motion: 'a missing motion video', getOriginalPath: undefined, deleteOnDisk: false },
])(
'should delete $motion that does not match what is extracted (deleteOnDisk: $deleteOnDisk)',
async ({ getOriginalPath, deleteOnDisk }) => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
const asset = AssetFactory.create({ livePhotoVideoId: motionAsset.id });
const oldMotion =
getOriginalPath && getForAsset({ ...motionAsset, originalPath: getOriginalPath(motionAsset) });
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(getForMetadataExtraction(asset));
mocks.asset.getById.mockImplementation((id) => Promise.resolve(id === motionAsset.id ? oldMotion : undefined));
mockReadTags({
Directory: 'foo/bar/',
MotionPhoto: 1,
MicroVideo: 1,
MicroVideoOffset: 1,
});
mocks.crypto.hashSha1.mockReturnValue(randomBytes(512));
mocks.asset.create.mockResolvedValue(AssetFactory.create({ type: AssetType.Video }));
const video = randomBytes(512);
mocks.storage.readFile.mockResolvedValue(video);
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
name: JobName.AssetDelete,
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
});
});
await sut.handleMetadataExtraction({ id: asset.id });
expect(mocks.job.queue).toHaveBeenNthCalledWith(1, {
name: JobName.AssetDelete,
data: { id: motionAsset.id, deleteOnDisk },
});
},
);
it('should not create a new motion photo video asset if the hash of the extracted video matches an existing asset', async () => {
const motionAsset = AssetFactory.create({ type: AssetType.Video, visibility: AssetVisibility.Hidden });
+4 -1
View File
@@ -789,9 +789,12 @@ export class MetadataService extends BaseService {
// (if it did, getByChecksum() would've returned a motionAsset with the same ID as livePhotoVideoId)
// note asset.livePhotoVideoId is not motionAsset.id yet
if (asset.livePhotoVideoId) {
// the old motion may be the user's own file (linked by livePhotoCID or manually), only remove ones Immich extracted
const oldMotion = await this.assetRepository.getById(asset.livePhotoVideoId);
const deleteOnDisk = !!oldMotion && StorageCore.isAndroidMotionPath(oldMotion.originalPath);
await this.jobRepository.queue({
name: JobName.AssetDelete,
data: { id: asset.livePhotoVideoId, deleteOnDisk: true },
data: { id: asset.livePhotoVideoId, deleteOnDisk },
});
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
}
+3
View File
@@ -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);
+20 -2
View File
@@ -399,13 +399,13 @@ export class BaseConfig implements VideoCodecSWConfig {
};
}
getToneMapping(videoStream: VideoStreamInfo) {
getToneMapping(videoStream: VideoStreamInfo, format = 'yuv420p') {
if (!this.shouldToneMap(videoStream)) {
return [];
}
const { primaries, transfer, matrix } = this.getColors();
const options = `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=yuv420p`;
const options = `tonemapx=tonemap=${this.config.tonemap}:desat=0:p=${primaries}:t=${transfer}:m=${matrix}:r=pc:peak=100:format=${format}`;
return [options];
}
@@ -818,6 +818,15 @@ export class QsvSwDecodeConfig extends BaseHWConfig {
return options;
}
getToneMapping(videoStream: VideoStreamInfo) {
// Output nv12 when tone-mapping instead of having ffmpeg convert from yuv420p
const options = super.getToneMapping(videoStream, 'nv12');
if (options.length === 0 && videoStream.pixelFormat !== 'nv12') {
options.push('format=nv12');
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
@@ -952,6 +961,15 @@ export class VaapiSwDecodeConfig extends BaseHWConfig {
return options;
}
getToneMapping(videoStream: VideoStreamInfo) {
// some drivers (e.g. Mesa radeonsi on AMD) reject yuv420p surfaces and FFmpeg doesn't auto-convert for VAAPI
const options = super.getToneMapping(videoStream, 'nv12');
if (options.length === 0 && videoStream.pixelFormat !== 'nv12') {
options.push('format=nv12');
}
return options;
}
getPresetOptions() {
let presetIndex = this.getPresetIndex();
if (presetIndex < 0) {
+26 -3
View File
@@ -1,5 +1,5 @@
import { describe, expect, it } from 'vitest';
import { getKeysDeep, globToPostgresRegex, unsetDeep } from 'src/utils/misc.js';
import { createLibraryMatcher, getKeysDeep, unsetDeep } from 'src/utils/misc.js';
describe('getKeysDeep', () => {
it('should handle an empty object', () => {
@@ -52,9 +52,24 @@ describe('unsetDeep', () => {
});
});
const matches = (glob: string, value: string) => new RegExp(globToPostgresRegex(glob)).test(value);
const matches = (glob: string, value: string) =>
!createLibraryMatcher({ importPaths: ['/'], exclusionPatterns: [glob] })(value);
describe('createLibraryMatcher', () => {
it.each([
{ path: '/photos/a.jpg', importPaths: ['/photos/'], isInLibrary: true },
{ path: '/other/a.jpg', importPaths: ['/photos/'], isInLibrary: false },
{ path: '/other/a.jpg', importPaths: ['/photos/', '/other/'], isInLibrary: true },
// unlike SQL `LIKE`, `_` and `%` are literal
{ path: '/photosX2020/a.jpg', importPaths: ['/photos_2020/'], isInLibrary: false },
{ path: '/photos/2020/a.jpg', importPaths: ['/photos%/'], isInLibrary: false },
])(
'should report $path as in library: $isInLibrary, with import paths $importPaths',
({ path, importPaths, isInLibrary }) => {
expect(createLibraryMatcher({ importPaths, exclusionPatterns: [] })(path)).toBe(isInLibrary);
},
);
describe('globToPostgresRegex', () => {
const testCases: [string, string, boolean][] = [
['**/Raw/**', '/foo/Raw/bar.jpg', true],
['**/Raw/**', '/foo/bar.jpg', false],
@@ -69,6 +84,14 @@ describe('globToPostgresRegex', () => {
// a bare `*` must not cross a path separator, unlike SQL `LIKE`'s `%`
['/path/*.*', '/path/photo.jpg', true],
['/path/*.*', '/path/2020/photo.jpg', false],
// matching is case-insensitive
['**/raw/**', '/foo/RAW/bar.jpg', true],
['**/*.ARW', '/foo/bar.arw', true],
// Postgres regexes disagreed with picomatch on these, or rejected them
['**/İstanbul/**', '/foo/istanbul/a.jpg', false],
['**/ΚΎΠΡΟΣ/**', '/foo/Κύπρος/a.jpg', true],
[String.raw`**/a\y/**`, '/foo/a/b.jpg', false],
[String.raw`**/a\k/**`, '/foo/ak/b.jpg', true],
];
it.each(testCases)('should match %s against %s as %s', (glob, value, expected) => {
+11 -4
View File
@@ -372,10 +372,17 @@ export const useSwagger = (app: INestApplication, { write }: { write: boolean })
writeFileSync(outputPath, JSON.stringify(patchOpenAPI(openApiDoc), null, 2), { encoding: 'utf8' });
};
// Compiles a glob to the equivalent Postgres regex (Postgres's Advanced Regular Expression
// dialect is a superset of what picomatch emits, so the two stay in sync with `picomatch.isMatch`,
// including which paths a lone `*` may cross vs `/`).
export const globToPostgresRegex = (glob: string) => picomatch.makeRe(glob).source;
// Offline detection and the re-online check must share one matcher, or an asset flips state on every scan
export const createLibraryMatcher = ({
importPaths,
exclusionPatterns,
}: {
importPaths: string[];
exclusionPatterns: string[];
}) => {
const isExcluded = picomatch(exclusionPatterns, { nocase: true });
return (path: string) => importPaths.some((importPath) => path.startsWith(importPath)) && !isExcluded(path);
};
export function clamp(value: number, min: number, max: number) {
return Math.max(min, Math.min(max, value));
+3
View File
@@ -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:
@@ -1,10 +1,14 @@
import { Kysely } from 'kysely';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetVisibility } from 'src/enum.js';
import { Insertable, Kysely } from 'kysely';
import { chunk } from 'lodash-es';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants.js';
import { AssetFileType, AssetOrder, AssetOrderBy, AssetType, AssetVisibility } from 'src/enum.js';
import { AssetRepository } from 'src/repositories/asset.repository.js';
import { LibraryRepository } from 'src/repositories/library.repository.js';
import { LoggingRepository } from 'src/repositories/logging.repository.js';
import { DB } from 'src/schema/index.js';
import { AssetTable } from 'src/schema/tables/asset.table.js';
import { BaseService } from 'src/services/base.service.js';
import { newMediumService } from 'test/medium.factory.js';
import { mediumFactory, newMediumService } from 'test/medium.factory.js';
import { factory } from 'test/small.factory.js';
import { getKyselyDB } from 'test/utils.js';
@@ -24,6 +28,13 @@ beforeAll(async () => {
defaultDatabase = await getKyselyDB();
});
const newExternalLibrary = async (ctx: ReturnType<typeof setup>['ctx']) => {
const { user } = await ctx.newUser();
return ctx
.get(LibraryRepository)
.create({ ownerId: user.id, name: 'library', importPaths: [], exclusionPatterns: [] });
};
// Metadata extraction is repeatable: probing improves, files get repaired,
// and a re-run has to be able to correct what an earlier run stored.
const audioRow = (assetId: string, n: number) => ({
@@ -564,10 +575,116 @@ describe(AssetRepository.name, () => {
});
});
describe('findLivePhotoMatch', () => {
type LibraryKind = 'upload' | 'external' | 'other external';
const setupLivePhoto = async ({ still, motion }: { still: LibraryKind; motion: LibraryKind }) => {
const { ctx, sut } = setup();
const { user } = await ctx.newUser();
const newLibrary = () =>
ctx
.get(LibraryRepository)
.create({ ownerId: user.id, name: 'library', importPaths: [], exclusionPatterns: [] });
const [external, otherExternal] = await Promise.all([newLibrary(), newLibrary()]);
const libraryIds = { upload: null, external: external.id, 'other external': otherExternal.id };
const livePhotoCID = 'live-photo-cid';
const [{ asset: stillAsset }, { asset: motionAsset }] = await Promise.all([
ctx.newAsset({ ownerId: user.id, type: AssetType.Image, libraryId: libraryIds[still] }),
ctx.newAsset({ ownerId: user.id, type: AssetType.Video, libraryId: libraryIds[motion] }),
]);
await Promise.all([
ctx.newExif({ assetId: stillAsset.id, livePhotoCID }),
ctx.newExif({ assetId: motionAsset.id, livePhotoCID }),
]);
const match = await sut.findLivePhotoMatch({
ownerId: user.id,
libraryId: libraryIds[still],
livePhotoCID,
otherAssetId: stillAsset.id,
type: AssetType.Video,
});
return { match, motionAsset };
};
it.each<LibraryKind>(['upload', 'external'])(
'should match a motion video in the same %s library',
async (library) => {
const { match, motionAsset } = await setupLivePhoto({ still: library, motion: library });
expect(match).toEqual({ id: motionAsset.id, ownerId: motionAsset.ownerId });
},
);
it.each<{ still: LibraryKind; motion: LibraryKind }>([
{ still: 'upload', motion: 'external' },
{ still: 'external', motion: 'upload' },
{ still: 'external', motion: 'other external' },
])('should not match a still in the $still library to a motion video in the $motion library', async (libraries) => {
const { match } = await setupLivePhoto(libraries);
expect(match).toBeUndefined();
});
});
describe('createAll', () => {
it('should return an empty array when given an empty input', async () => {
const { sut } = setup();
await expect(sut.createAll([])).resolves.toStrictEqual([]);
});
});
describe('detectOfflineExternalAssets', () => {
it('should offline the assets the matcher rejects and return how many it offlined', async () => {
const { ctx, sut } = setup();
const [library, otherLibrary] = await Promise.all([newExternalLibrary(ctx), newExternalLibrary(ctx)]);
const deletedAt = new Date('2020-01-01T00:00:00.000Z');
const newExternalAsset = (originalPath: string, dto: Partial<Insertable<AssetTable>> = {}) =>
ctx.newAsset({ ownerId: library.ownerId, libraryId: library.id, isExternal: true, originalPath, ...dto });
const [{ asset: kept }, { asset: rejected }, { asset: alreadyOffline }, { asset: otherLibraryAsset }] =
await Promise.all([
newExternalAsset('/keep/a.jpg'),
newExternalAsset('/reject/b.jpg'),
newExternalAsset('/reject/c.jpg', { isOffline: true, deletedAt }),
ctx.newAsset({
ownerId: otherLibrary.ownerId,
libraryId: otherLibrary.id,
isExternal: true,
originalPath: '/reject/d.jpg',
}),
]);
await expect(sut.detectOfflineExternalAssets(library.id, (path) => path.startsWith('/keep/'))).resolves.toBe(1);
await expect(sut.getById(kept.id)).resolves.toMatchObject({ isOffline: false, deletedAt: null });
await expect(sut.getById(rejected.id)).resolves.toMatchObject({ isOffline: true, deletedAt: expect.any(Date) });
await expect(sut.getById(alreadyOffline.id)).resolves.toMatchObject({ isOffline: true, deletedAt });
await expect(sut.getById(otherLibraryAsset.id)).resolves.toMatchObject({ isOffline: false, deletedAt: null });
});
it('should offline each asset once when the same library is checked concurrently', async () => {
const { ctx, sut } = setup();
const library = await newExternalLibrary(ctx);
const assets = Array.from({ length: JOBS_LIBRARY_PAGINATION_SIZE * 2 }, (_, index) =>
mediumFactory.assetInsert({
ownerId: library.ownerId,
libraryId: library.id,
isExternal: true,
originalPath: `/reject/${index}.jpg`,
}),
);
for (const batch of chunk(assets, 1000)) {
await sut.createAll(batch);
}
const counts = await Promise.all([
sut.detectOfflineExternalAssets(library.id, () => false),
sut.detectOfflineExternalAssets(library.id, () => false),
]);
expect(counts[0] + counts[1]).toBe(assets.length);
}, 60_000);
});
});
@@ -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',
});
});
});
@@ -1,9 +1,11 @@
import { Kysely } from 'kysely';
import { chunk } from 'lodash-es';
import { existsSync } from 'node:fs';
import { copyFile, mkdir, mkdtemp, rm, utimes, writeFile } from 'node:fs/promises';
import { tmpdir } from 'node:os';
import { dirname, join, resolve } from 'node:path';
import type { SystemConfig } from 'src/dtos/config.dto.js';
import { JOBS_LIBRARY_PAGINATION_SIZE } from 'src/constants.js';
import { StorageCore } from 'src/cores/storage.core.js';
import { AssetStatus, JobName, JobStatus } from 'src/enum.js';
import { AssetJobRepository } from 'src/repositories/asset-job.repository.js';
@@ -20,7 +22,7 @@ import { WebsocketRepository } from 'src/repositories/websocket.repository.js';
import { DB } from 'src/schema/index.js';
import { LibraryService } from 'src/services/library.service.js';
import { systemConfigStub } from 'test/fixtures/system-config.stub.js';
import { MediumTestContext, testAssetsDir } from 'test/medium.factory.js';
import { MediumTestContext, mediumFactory, testAssetsDir } from 'test/medium.factory.js';
import { newUuid } from 'test/small.factory.js';
import { getKyselyDB } from 'test/utils.js';
@@ -574,6 +576,44 @@ describe(LibraryService.name, () => {
await ctx.scan(library.id);
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([asset1]);
});
// Postgres case folding disagreed with picomatch on these: `İ` folds to `i`, but `Σ` does not fold to `ς`
it.each([
{ pattern: '**/İstanbul/**', folder: 'istanbul', isExcluded: false },
{ pattern: '**/ΚΎΠΡΟΣ/**', folder: 'Κύπρος', isExcluded: true },
])('should apply $pattern to $folder the same way on every scan', async ({ pattern, folder, isExcluded }) => {
const { sut, ctx } = setup();
const asset = await createFile(join(importRoot, folder, 'asset.png'));
const library = await ctx.createLibrary({ importPaths: [importRoot] });
await ctx.scan(library.id);
await sut.update(library.id, { exclusionPatterns: [pattern] });
const expected = isExcluded ? [] : [asset];
await ctx.scan(library.id);
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual(expected);
await ctx.scan(library.id);
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual(expected);
});
it('should offline a missing asset when an exclusion pattern is not a valid Postgres regex', async () => {
const { ctx } = setup();
const kept = await createFile(join(importPath, 'kept.png'));
const missing = await createFile(join(importPath, 'missing.png'));
const library = await ctx.createLibrary({
importPaths: [importPath],
exclusionPatterns: [String.raw`**/a\k/**`],
});
await ctx.scan(library.id);
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([kept, missing]);
await rm(missing);
await ctx.scan(library.id);
await expect(ctx.getAssetPaths(library.id)).resolves.toEqual([kept]);
});
});
describe('watch', () => {
@@ -646,6 +686,35 @@ describe(LibraryService.name, () => {
expect(updated).toEqual(expect.objectContaining({ isOffline: true }));
expect(updated?.deletedAt).toBeInstanceOf(Date);
});
it('should not exhaust the database pool when more libraries are checked at once than it has connections', async () => {
const { sut, ctx } = setup();
const assetRepo = ctx.get(AssetRepository);
// the pool has 10 connections, and each library spans more than one batch so it offlines assets mid-read
const libraries = await Promise.all(
Array.from({ length: 10 }, () => ctx.createLibrary({ importPaths: [importPath] })),
);
for (const library of libraries) {
const assets = Array.from({ length: JOBS_LIBRARY_PAGINATION_SIZE + 1 }, (_, index) =>
mediumFactory.assetInsert({
ownerId: library.ownerId,
libraryId: library.id,
originalPath: join(outsidePath, `${index}.jpg`),
isExternal: true,
}),
);
for (const batch of chunk(assets, 1000)) {
await assetRepo.createAll(batch);
}
}
await expect(Promise.all(libraries.map(({ id }) => sut.handleQueueSyncAssets({ id })))).resolves.toEqual(
libraries.map(() => JobStatus.Success),
);
for (const { id } of libraries) {
await expect(ctx.getAssetPaths(id)).resolves.toEqual([]);
}
}, 60_000);
});
describe('handleSyncAssets', () => {
+4
View File
@@ -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 -1
View File
@@ -33,7 +33,7 @@
},
"dependencies": {
"@formatjs/icu-messageformat-parser": "^3.0.0",
"@futo-org/backups-orchestrator-ui": "1.11.5",
"@futo-org/backups-orchestrator-ui": "1.12.1",
"@immich/justified-layout-wasm": "^0.4.3",
"@immich/sdk": "workspace:*",
"@immich/ui": "^0.91.0",
@@ -18,7 +18,6 @@ vi.mock('#lib/services/database-backups.service.ts', async (importOriginal) => {
};
});
describe('MaintenanceBackupEntry', () => {
beforeEach(() => {
vi.useFakeTimers();
@@ -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}
+77
View File
@@ -0,0 +1,77 @@
import { asMutation, queryClient } from '#lib/queries/client.ts';
import {
createFileFolder,
getBaseUrl,
listFileFolders,
listFiles,
permanentlyDeleteFile,
restoreFile,
trashFile,
updateFile,
type FileVaultEntryDto,
} from '@immich/sdk';
import { queryOptions } from '@tanstack/svelte-query';
export const fileVaultKeys = { all: ['file-vault'] as const };
export const getFileVaultQuery = (parentId?: string, trash = false, offset = 0) =>
queryOptions({
queryKey: [...fileVaultKeys.all, parentId, trash, offset],
queryFn: ({ signal }) => listFiles({ parentId, trash: trash ? 'true' : 'false', offset }, { signal }),
});
export const getFileVaultFoldersQuery = () =>
queryOptions({ queryKey: [...fileVaultKeys.all, 'folders'], queryFn: ({ signal }) => listFileFolders({ signal }) });
const refresh = () => queryClient.invalidateQueries({ queryKey: fileVaultKeys.all });
const options = { onSuccess: refresh, meta: { error: { toast: false as const } } };
export const createFileFolderMutation = () => asMutation(createFileFolder, options);
export const updateFileMutation = () => asMutation(updateFile, options);
export const trashFileMutation = () => asMutation(trashFile, options);
export const restoreFileMutation = () => asMutation(restoreFile, options);
export const permanentlyDeleteFileMutation = () => asMutation(permanentlyDeleteFile, options);
export const fileVaultDownloadUrl = (id: string) => `${getBaseUrl()}/file-vault/${encodeURIComponent(id)}/download`;
export class FileVaultUploadError extends Error {
constructor(public status: number) {
super(`File upload failed (${status})`);
}
}
export const uploadVaultFile = (
file: File,
parentId: string | undefined,
onProgress: (bytes: number, total: number) => void,
signal: AbortSignal,
) =>
new Promise<FileVaultEntryDto>((resolve, reject) => {
const query = new URLSearchParams({ name: file.name });
if (parentId) {
query.set('parentId', parentId);
}
const xhr = new XMLHttpRequest();
const abort = () => xhr.abort();
xhr.open('POST', `${getBaseUrl()}/file-vault/upload?${query}`);
xhr.withCredentials = true;
xhr.setRequestHeader('Content-Type', 'application/octet-stream');
xhr.upload.addEventListener('progress', (event) => onProgress(event.loaded, event.total || file.size));
xhr.addEventListener('load', () => {
if (xhr.status >= 200 && xhr.status < 300) {
try {
resolve(JSON.parse(xhr.responseText) as FileVaultEntryDto);
} catch (error) {
reject(error);
}
} else {
reject(new FileVaultUploadError(xhr.status));
}
});
xhr.addEventListener('error', () => reject(new FileVaultUploadError(xhr.status)));
xhr.addEventListener('abort', () => reject(new DOMException('Upload cancelled', 'AbortError')));
xhr.addEventListener('loadend', () => signal.removeEventListener('abort', abort));
signal.addEventListener('abort', abort, { once: true });
if (signal.aborted) {
reject(new DOMException('Upload cancelled', 'AbortError'));
return;
}
xhr.send(file);
});
export const uploadVaultFileMutation = () => asMutation(uploadVaultFile, options);
+1
View File
@@ -35,6 +35,7 @@ const asQueryString = (params?: Record<string, QueryValue | undefined>) => {
};
export const Route = {
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),
+32
View File
@@ -0,0 +1,32 @@
import { t } from 'svelte-i18n';
import { get } from 'svelte/store';
const russian = {
title: 'Файлы',
description: 'Личные документы и рабочие папки',
root: 'Мои файлы',
new_folder: 'Новая папка',
upload_files: 'Загрузить файлы',
upload_folder: 'Загрузить папку',
empty: 'В этой папке пока нет файлов',
empty_trash: 'Корзина пуста',
move_to: 'Переместить в папку',
folder_name: 'Название папки',
private: 'Доступ к этим файлам есть только у вас.',
used: 'Занято',
uploading: 'Загрузка',
error: 'Не удалось выполнить действие. Проверьте соединение и повторите попытку.',
conflict: 'Такое имя уже существует. Переименуйте файл или выберите другую папку.',
unavailable: 'Файл или папка недоступны. Обновите список.',
quota: 'Недостаточно места или превышен допустимый размер файла.',
invalid: String.raw`Проверьте название и выбранную папку. Название не должно содержать / или \.`,
purge_confirm: 'Удалить навсегда? Восстановить этот файл или папку с содержимым будет невозможно.',
trash_confirm: 'Переместить в корзину? Папка будет перемещена вместе с содержимым.',
drop: 'Перетащите сюда файлы или папки',
limit: 'Максимальный размер одного файла',
selected: 'Выбрано файлов',
} as const;
export type FileVaultLabel = keyof typeof russian;
export const fileVaultLabel = (language: string | null | undefined, key: FileVaultLabel) =>
language?.startsWith('ru') ? russian[key] : get(t)(`file_vault.${key}`);
+428
View File
@@ -0,0 +1,428 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { page } from '$app/state';
import UserPageLayout from '#lib/components/layouts/UserPageLayout.svelte';
import { createPreloadedQuery, queryClient } from '#lib/queries/client.ts';
import {
createFileFolderMutation,
fileVaultDownloadUrl,
fileVaultKeys,
getFileVaultFoldersQuery,
getFileVaultQuery,
permanentlyDeleteFileMutation,
restoreFileMutation,
trashFileMutation,
updateFileMutation,
uploadVaultFileMutation,
FileVaultUploadError,
} from '#lib/queries/file-vault.query.ts';
import { Route } from '#lib/route.ts';
import { fileVaultLabel } from '#lib/utils/file-vault-i18n.ts';
import type { FileVaultEntryDto, FileVaultFolderDto } from '@immich/sdk';
import { createMutation } from '@tanstack/svelte-query';
import { locale, t } from 'svelte-i18n';
import { onDestroy } from 'svelte';
import { SvelteMap, SvelteSet } from 'svelte/reactivity';
const parentId = $derived(page.url.searchParams.get('folder') ?? undefined);
const trash = $derived(page.url.searchParams.get('trash') === 'true');
const offset = $derived(Math.max(0, Number(page.url.searchParams.get('offset')) || 0));
const query = createPreloadedQuery(() => getFileVaultQuery(parentId, trash, offset));
const entries = $derived(query.data.entries);
const labels = $derived((key: Parameters<typeof fileVaultLabel>[1]) => fileVaultLabel($locale, key));
const createFolder = createMutation(createFileFolderMutation);
const update = createMutation(updateFileMutation);
const remove = createMutation(trashFileMutation);
const restore = createMutation(restoreFileMutation);
const purge = createMutation(permanentlyDeleteFileMutation);
const upload = createMutation(uploadVaultFileMutation);
let error = $state('');
let busy = $state(false);
let progress = $state('');
let abortController: AbortController | undefined;
let filesInput: HTMLInputElement;
let folderInput: HTMLInputElement;
let dialog: HTMLDialogElement;
let modal = $state<'create' | 'rename' | 'move' | 'trash' | 'purge' | null>(null);
let selected = $state<FileVaultEntryDto | undefined>();
let name = $state('');
let destination = $state('');
let folders = $state<FileVaultFolderDto[]>([]);
$effect(() => {
if (modal) {
dialog?.showModal();
} else {
dialog?.close();
}
});
onDestroy(() => abortController?.abort());
const size = (bytes: number) =>
bytes < 1024
? new Intl.NumberFormat($locale ?? 'ru', { style: 'unit', unit: 'byte', maximumFractionDigits: 0 }).format(bytes)
: new Intl.NumberFormat($locale ?? 'ru', {
style: 'unit',
unit: bytes >= 1024 ** 3 ? 'gigabyte' : bytes >= 1024 ** 2 ? 'megabyte' : 'kilobyte',
maximumFractionDigits: 1,
}).format(bytes / (bytes >= 1024 ** 3 ? 1024 ** 3 : bytes >= 1024 ** 2 ? 1024 ** 2 : 1024));
const folderPath = (folder: FileVaultFolderDto) => {
const parts = [folder.name];
const seen = new SvelteSet([folder.id]);
let parent = folder.parentId;
while (parent && !seen.has(parent)) {
seen.add(parent);
const item = folders.find((entry) => entry.id === parent);
if (!item) {
break;
}
parts.unshift(item.name);
parent = item.parentId;
}
return parts.join(' / ');
};
const isDescendant = (folder: FileVaultFolderDto) => {
const seen = new SvelteSet<string>();
let current: FileVaultFolderDto | undefined = folder;
while (current && !seen.has(current.id)) {
if (current.id === selected?.id) {
return true;
}
seen.add(current.id);
current = folders.find((entry) => entry.id === current?.parentId);
}
return false;
};
const showError = (failure: unknown) => {
const status = failure instanceof FileVaultUploadError ? failure.status : (failure as { status?: number })?.status;
error = labels(
status === 409
? 'conflict'
: status === 404
? 'unavailable'
: status === 413 || status === 507
? 'quota'
: status === 400
? 'invalid'
: 'error',
);
};
async function run(action: () => Promise<unknown>) {
error = '';
busy = true;
try {
await action();
modal = null;
} catch (error_) {
showError(error_);
} finally {
busy = false;
}
}
async function openModal(kind: NonNullable<typeof modal>, entry?: FileVaultEntryDto) {
selected = entry;
name = entry?.name ?? '';
destination = entry?.parentId ?? '';
error = '';
if (kind === 'move') {
try {
folders = await queryClient.fetchQuery(getFileVaultFoldersQuery());
} catch (error_) {
showError(error_);
return;
}
}
modal = kind;
}
async function submit(event: SubmitEvent) {
event.preventDefault();
await run(async () => {
if (modal === 'create') {
await createFolder.mutateAsync([{ fileVaultCreateFolderDto: { name, parentId: parentId ?? null } }]);
}
if (selected && modal === 'rename') {
await update.mutateAsync([{ id: selected.id, fileVaultUpdateDto: { name } }]);
}
if (selected && modal === 'move') {
await update.mutateAsync([{ id: selected.id, fileVaultUpdateDto: { parentId: destination || null } }]);
}
if (selected && modal === 'trash') {
await remove.mutateAsync([{ id: selected.id }]);
}
if (selected && modal === 'purge') {
await purge.mutateAsync([{ id: selected.id }]);
}
});
}
async function uploadFiles(files: File[]) {
if (busy || trash || files.length === 0) {
return;
}
error = '';
busy = true;
abortController = new AbortController();
const uploadParent = parentId;
let completed = 0;
try {
const known = new SvelteMap<string, string>();
const existing = await queryClient.fetchQuery(getFileVaultFoldersQuery());
for (const folder of existing) {
known.set(`${folder.parentId ?? ''}\0${folder.name.toLocaleLowerCase()}`, folder.id);
}
for (const file of files) {
if (abortController.signal.aborted) {
break;
}
let target = uploadParent;
const parts = file.webkitRelativePath.split('/').slice(0, -1);
for (const part of parts) {
const key = `${target ?? ''}\0${part.toLocaleLowerCase()}`;
let id = known.get(key);
if (!id) {
const created = await createFolder.mutateAsync([
{ fileVaultCreateFolderDto: { name: part, parentId: target ?? null } },
]);
id = created.id;
known.set(key, id);
}
target = id;
}
if (file.size > query.data.maxFileBytes) {
throw new FileVaultUploadError(413);
}
await upload.mutateAsync([
file,
target,
(bytes, total) => {
progress = `${completed + 1}/${files.length} · ${file.name} · ${total ? Math.round((bytes / total) * 100) : 0}%`;
},
abortController.signal,
]);
completed++;
}
} catch (error_) {
if (!(error_ instanceof DOMException && error_.name === 'AbortError')) {
showError(error_);
}
} finally {
busy = false;
progress = '';
filesInput.value = '';
folderInput.value = '';
await queryClient.invalidateQueries({ queryKey: fileVaultKeys.all });
}
}
</script>
<UserPageLayout title={labels('title')} description={labels('description')}>
<section class="mx-auto w-full max-w-6xl p-4 md:p-6" aria-label={labels('title')}>
<div class="mb-5 flex flex-wrap items-center gap-2">
<a class="vault-button" aria-current={trash ? undefined : 'page'} href={Route.files()}>{labels('root')}</a>
<a class="vault-button" aria-current={trash ? 'page' : undefined} href={Route.files({ trash: true })}
>{$t('trash')}</a
>
{#if !trash}
<button type="button" class="vault-button" disabled={busy} onclick={() => openModal('create')}
>{labels('new_folder')}</button
>
<button type="button" class="vault-button vault-primary" disabled={busy} onclick={() => filesInput.click()}
>{labels('upload_files')}</button
>
<button type="button" class="vault-button" disabled={busy} onclick={() => folderInput.click()}
>{labels('upload_folder')}</button
>
{/if}
<input
bind:this={filesInput}
class="hidden"
type="file"
multiple
onchange={(event) => uploadFiles([...(event.currentTarget.files ?? [])])}
aria-label={labels('upload_files')}
/>
<input
bind:this={folderInput}
class="hidden"
type="file"
webkitdirectory
multiple
onchange={(event) => uploadFiles([...(event.currentTarget.files ?? [])])}
aria-label={labels('upload_folder')}
/>
</div>
{#if !trash}
<nav class="mb-4 flex flex-wrap gap-2 text-sm" aria-label={$t('folders')}>
<a href={Route.files()}>{labels('root')}</a>
{#each query.data.breadcrumbs as folder (folder.id)}<span>/</span><a href={Route.files({ folder: folder.id })}
>{folder.name}</a
>{/each}
</nav>
{/if}
<p class="mb-4 text-sm opacity-70">
{labels('private')}
{labels('used')}: {size(query.data.usedBytes)} · {labels('limit')}: {size(query.data.maxFileBytes)}
</p>
{#if error}<p role="alert" class="mb-4 rounded-lg bg-red-100 p-3 text-red-900 dark:bg-red-950 dark:text-red-100">
{error}
</p>{/if}
{#if busy && progress}<div role="status" class="mb-4 flex items-center gap-3">
<span>{labels('uploading')}: {progress}</span><button
type="button"
class="vault-button"
onclick={() => abortController?.abort()}>{$t('cancel')}</button
>
</div>{/if}
<div class="overflow-x-auto rounded-xl border border-gray-300 dark:border-gray-700">
<table class="w-full text-left text-sm">
<thead class="bg-gray-100 dark:bg-gray-900"
><tr
><th class="p-3">{$t('name')}</th><th class="p-3">{$t('file_size')}</th><th class="p-3"
><span class="sr-only">{$t('select')}</span></th
></tr
></thead
>
<tbody>
{#each entries as entry (entry.id)}
<tr class="border-t border-gray-200 dark:border-gray-700" data-file-id={entry.id}>
<td class="max-w-80 p-3 wrap-break-word">
{#if entry.isFolder}<span aria-hidden="true">📁 </span>{:else}<span aria-hidden="true">📄 </span>{/if}
{#if entry.isFolder && !trash}<a
class="font-medium underline-offset-4 hover:underline"
href={Route.files({ folder: entry.id })}>{entry.name}</a
>{:else}<span>{entry.name}</span>{/if}
</td>
<td class="p-3 whitespace-nowrap">{entry.isFolder ? '—' : size(entry.size)}</td>
<td class="p-3"
><div class="flex flex-wrap justify-end gap-2">
{#if trash}
<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => run(() => restore.mutateAsync([{ id: entry.id }]))}>{$t('restore')}</button
>
<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => openModal('rename', entry)}>{$t('rename')}</button
>
<button
type="button"
class="vault-button text-red-700 dark:text-red-300"
disabled={busy}
onclick={() => openModal('purge', entry)}>{$t('delete_permanently')}</button
>
{:else}
<a
class="vault-button"
href={fileVaultDownloadUrl(entry.id)}
download={entry.name + (entry.isFolder ? '.zip' : '')}>{$t('download')}</a
>
<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => openModal('rename', entry)}>{$t('rename')}</button
>
<button type="button" class="vault-button" disabled={busy} onclick={() => openModal('move', entry)}
>{$t('move')}</button
>
<button type="button" class="vault-button" disabled={busy} onclick={() => openModal('trash', entry)}
>{$t('delete')}</button
>
{/if}
</div></td
>
</tr>
{/each}
{#if entries.length === 0}<tr
><td colspan="3" class="p-12 text-center opacity-60">{labels(trash ? 'empty_trash' : 'empty')}</td></tr
>{/if}
</tbody>
</table>
</div>
<div class="mt-4 flex gap-2">
{#if offset > 0}<button
type="button"
class="vault-button"
disabled={busy}
onclick={() =>
goto(Route.files({ folder: parentId, trash: trash || undefined, offset: Math.max(0, offset - 100) }))}
>{$t('previous')}</button
>{/if}
{#if query.data.hasMore}<button
type="button"
class="vault-button"
disabled={busy}
onclick={() => goto(Route.files({ folder: parentId, trash: trash || undefined, offset: offset + 100 }))}
>{$t('next')}</button
>{/if}
</div>
</section>
</UserPageLayout>
<dialog
bind:this={dialog}
onclose={() => (modal = null)}
class="w-[min(92vw,30rem)] rounded-2xl bg-white p-6 text-gray-900 shadow-2xl backdrop:bg-black/50 dark:bg-gray-900 dark:text-gray-100"
aria-labelledby="vault-dialog-title"
>
<form onsubmit={submit}>
<h2 id="vault-dialog-title" class="mb-5 text-lg font-semibold">
{modal === 'create'
? labels('new_folder')
: modal === 'move'
? labels('move_to')
: modal === 'rename'
? $t('rename')
: modal === 'purge'
? $t('delete_permanently')
: $t('trash')}
</h2>
{#if modal === 'create' || modal === 'rename'}<label class="block"
>{$t('name')}<input
class="mt-2 w-full rounded-lg border bg-transparent p-3"
bind:value={name}
required
maxlength="240"
disabled={busy}
/></label
>{/if}
{#if modal === 'move'}<label class="block"
>{$t('folder')}<select
class="mt-2 w-full rounded-lg border bg-white p-3 dark:bg-gray-900"
bind:value={destination}
><option value="">{labels('root')}</option
>{#each folders.filter((folder) => !isDescendant(folder)) as folder (folder.id)}<option value={folder.id}
>{folderPath(folder)}</option
>{/each}</select
></label
>{/if}
{#if modal === 'trash' || modal === 'purge'}<p>{labels(modal === 'trash' ? 'trash_confirm' : 'purge_confirm')}</p>
<p class="mt-3 font-semibold break-all">{selected?.name}</p>{/if}
{#if error}<p role="alert" class="mt-3 text-red-600">{error}</p>{/if}
<div class="mt-6 flex justify-end gap-3">
<button type="button" class="vault-button" disabled={busy} onclick={() => (modal = null)}>{$t('cancel')}</button
><button type="submit" class="vault-button vault-primary" disabled={busy}>{$t('confirm')}</button>
</div>
</form>
</dialog>
<style>
.vault-button {
border: 1px solid color-mix(in srgb, currentColor 22%, transparent);
border-radius: 0.5rem;
padding: 0.45rem 0.75rem;
font-size: 0.875rem;
white-space: nowrap;
}
.vault-button:disabled {
opacity: 0.45;
cursor: wait;
}
.vault-button[aria-current='page'],
.vault-primary {
background: var(--color-immich-primary, #4258ca);
color: white;
}
</style>
+16
View File
@@ -0,0 +1,16 @@
import { queryClient } from '#lib/queries/client.ts';
import { getFileVaultQuery } from '#lib/queries/file-vault.query.ts';
import { authenticate } from '#lib/utils/auth.ts';
import { fileVaultLabel } from '#lib/utils/file-vault-i18n.ts';
import { locale } from 'svelte-i18n';
import { get } from 'svelte/store';
import type { PageLoad } from './$types';
export const load = (async ({ url }) => {
await authenticate(url);
const parentId = url.searchParams.get('folder') ?? undefined;
const trash = url.searchParams.get('trash') === 'true';
const offset = Math.max(0, Number(url.searchParams.get('offset')) || 0);
await queryClient.query(getFileVaultQuery(parentId, trash, offset));
return { meta: { title: fileVaultLabel(get(locale), 'title') } };
}) satisfies PageLoad;