feat(storage): добавлены маршруты и экраны для управления текстовыми секретами и 2FA токенами

This commit is contained in:
2026-05-13 20:39:55 +03:00
parent c6df089668
commit 5777f8e459
36 changed files with 1894 additions and 9 deletions

View File

@@ -0,0 +1,15 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IVaultsManager
import java.util.UUID
class FindStorageUseCase(
private val vaultsManager: IVaultsManager,
) {
fun find(storageUuid: UUID): IStorage? {
return vaultsManager.vaults.value
.flatMap { it.storages.value }
.firstOrNull { it.uuid == storageUuid }
}
}

View File

@@ -0,0 +1,135 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretRecord
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
class ManageTextSecretsUseCase {
private val mutex = Mutex()
suspend fun list(storageInfo: IStorageInfo): List<TextSecretRecord> = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock emptyList()
readAll(storage)
}
suspend fun get(storageInfo: IStorageInfo, id: String): TextSecretRecord? = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock null
readAll(storage).firstOrNull { it.id == id }
}
suspend fun create(storageInfo: IStorageInfo, title: String, items: List<TextSecretEntryRecord>): TextSecretRecord =
mutex.withLock {
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
val next = TextSecretRecord(
id = UUID.randomUUID().toString(),
title = title.trim(),
items = items.normalizeItems(),
)
val updated = readAll(storage).toMutableList().apply { add(next) }
writeAll(storage, updated)
next
}
suspend fun update(storageInfo: IStorageInfo, secret: TextSecretRecord): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val current = readAll(storage)
val index = current.indexOfFirst { it.id == secret.id }
if (index < 0) return@withLock false
val updatedSecret = secret.copy(
title = secret.title.trim(),
items = secret.items.normalizeItems(),
)
val updated = current.toMutableList().apply { this[index] = updatedSecret }
writeAll(storage, updated)
true
}
suspend fun delete(storageInfo: IStorageInfo, id: String): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val current = readAll(storage)
val updated = current.filterNot { it.id == id }
if (updated.size == current.size) return@withLock false
writeAll(storage, updated)
true
}
private suspend fun readAll(storage: IStorage): List<TextSecretRecord> {
return StorageDomainJsonIo.readArray(storage, StorageDomainDataFiles.TEXT_SECRETS_FILE)
.mapNotNull { parseSecret(it) }
}
private suspend fun writeAll(storage: IStorage, records: List<TextSecretRecord>) {
StorageDomainJsonIo.writeArray(
storage = storage,
fileName = StorageDomainDataFiles.TEXT_SECRETS_FILE,
data = records.map { encodeSecret(it) },
)
}
private fun parseSecret(element: JsonElement): TextSecretRecord? {
val obj = element as? JsonObject ?: return null
val id = obj["id"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null
val title = obj["title"]?.jsonPrimitive?.contentOrNull ?: return null
val itemsElement = obj["items"] ?: JsonArray(emptyList())
val items = (itemsElement as? JsonArray)?.mapNotNull { parseItem(it) } ?: emptyList()
return TextSecretRecord(
id = id,
title = title,
items = items,
)
}
private fun parseItem(element: JsonElement): TextSecretEntryRecord? {
val obj = element as? JsonObject ?: return null
val value = obj["value"]?.jsonPrimitive?.contentOrNull ?: return null
val label = obj["label"]?.jsonPrimitive?.contentOrNull
return TextSecretEntryRecord(
label = label,
value = value,
)
}
private fun encodeSecret(record: TextSecretRecord): JsonElement = buildJsonObject {
put("id", JsonPrimitive(record.id))
put("title", JsonPrimitive(record.title))
put(
"items",
buildJsonArray {
record.items.forEach { item ->
add(
buildJsonObject {
item.label?.let { put("label", JsonPrimitive(it)) }
put("value", JsonPrimitive(item.value))
},
)
}
},
)
}
private fun List<TextSecretEntryRecord>.normalizeItems(): List<TextSecretEntryRecord> =
this.mapNotNull { item ->
val value = item.value.trim()
if (value.isBlank()) {
null
} else {
TextSecretEntryRecord(
label = item.label?.trim().takeUnless { it.isNullOrBlank() },
value = value,
)
}
}
}

View File

@@ -0,0 +1,110 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.TwoFaTokenRecord
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageInfo
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.jsonPrimitive
import java.util.UUID
class ManageTwoFaTokensUseCase {
private val mutex = Mutex()
suspend fun list(storageInfo: IStorageInfo): List<TwoFaTokenRecord> = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock emptyList()
readAll(storage)
}
suspend fun get(storageInfo: IStorageInfo, id: String): TwoFaTokenRecord? = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock null
readAll(storage).firstOrNull { it.id == id }
}
suspend fun create(
storageInfo: IStorageInfo,
issuer: String,
account: String,
secret: String,
notes: String? = null,
): TwoFaTokenRecord = mutex.withLock {
val storage = storageInfo as? IStorage ?: error("Storage is not writable")
val next = TwoFaTokenRecord(
id = UUID.randomUUID().toString(),
issuer = issuer.trim(),
account = account.trim(),
secret = secret.trim(),
notes = notes?.trim().takeUnless { it.isNullOrBlank() },
)
val updated = readAll(storage).toMutableList().apply { add(next) }
writeAll(storage, updated)
next
}
suspend fun update(storageInfo: IStorageInfo, token: TwoFaTokenRecord): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val updatedToken = token.copy(
issuer = token.issuer.trim(),
account = token.account.trim(),
secret = token.secret.trim(),
notes = token.notes?.trim().takeUnless { it.isNullOrBlank() },
)
val current = readAll(storage)
val index = current.indexOfFirst { it.id == token.id }
if (index < 0) return@withLock false
val updated = current.toMutableList().apply { this[index] = updatedToken }
writeAll(storage, updated)
true
}
suspend fun delete(storageInfo: IStorageInfo, id: String): Boolean = mutex.withLock {
val storage = storageInfo as? IStorage ?: return@withLock false
val current = readAll(storage)
val updated = current.filterNot { it.id == id }
if (updated.size == current.size) return@withLock false
writeAll(storage, updated)
true
}
private suspend fun readAll(storage: IStorage): List<TwoFaTokenRecord> {
return StorageDomainJsonIo.readArray(storage, StorageDomainDataFiles.TWO_FA_TOKENS_FILE)
.mapNotNull { parseToken(it) }
}
private suspend fun writeAll(storage: IStorage, records: List<TwoFaTokenRecord>) {
StorageDomainJsonIo.writeArray(
storage = storage,
fileName = StorageDomainDataFiles.TWO_FA_TOKENS_FILE,
data = records.map { record -> encodeToken(record) },
)
}
private fun parseToken(element: JsonElement): TwoFaTokenRecord? {
val obj = element as? JsonObject ?: return null
val id = obj["id"]?.jsonPrimitive?.contentOrNull?.takeIf { it.isNotBlank() } ?: return null
val issuer = obj["issuer"]?.jsonPrimitive?.contentOrNull ?: return null
val account = obj["account"]?.jsonPrimitive?.contentOrNull ?: return null
val secret = obj["secret"]?.jsonPrimitive?.contentOrNull ?: return null
val notes = obj["notes"]?.jsonPrimitive?.contentOrNull
return TwoFaTokenRecord(
id = id,
issuer = issuer,
account = account,
secret = secret,
notes = notes,
)
}
private fun encodeToken(record: TwoFaTokenRecord): JsonElement = buildJsonObject {
put("id", JsonPrimitive(record.id))
put("issuer", JsonPrimitive(record.issuer))
put("account", JsonPrimitive(record.account))
put("secret", JsonPrimitive(record.secret))
record.notes?.let { put("notes", JsonPrimitive(it)) }
}
}

View File

@@ -0,0 +1,9 @@
package com.github.nullptroma.wallenc.usecases
/**
* Единая точка с путями обычных JSON-файлов пользовательских доменных данных.
*/
object StorageDomainDataFiles {
const val TWO_FA_TOKENS_FILE = "/two-fa-tokens.json"
const val TEXT_SECRETS_FILE = "/text-secrets.json"
}

View File

@@ -0,0 +1,40 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonElement
import kotlinx.serialization.json.buildJsonArray
internal object StorageDomainJsonIo {
val json: Json = Json {
prettyPrint = true
ignoreUnknownKeys = true
explicitNulls = false
}
suspend fun readArray(storage: IStorage, fileName: String): JsonArray {
return try {
val text = storage.accessor.openRead(fileName).use { stream ->
stream.readBytes().decodeToString()
}
if (text.isBlank()) {
JsonArray(emptyList())
} else {
when (val parsed = json.parseToJsonElement(text)) {
is JsonArray -> parsed
else -> JsonArray(emptyList())
}
}
} catch (_: Exception) {
JsonArray(emptyList())
}
}
suspend fun writeArray(storage: IStorage, fileName: String, data: List<JsonElement>) {
val payload = buildJsonArray { data.forEach { add(it) } }
storage.accessor.openWrite(fileName).use { stream ->
stream.write(json.encodeToString(JsonArray.serializer(), payload).encodeToByteArray())
}
}
}

View File

@@ -0,0 +1,222 @@
package com.github.nullptroma.wallenc.usecases
import com.github.nullptroma.wallenc.domain.datatypes.DataPage
import com.github.nullptroma.wallenc.domain.datatypes.StorageEncryptionInfo
import com.github.nullptroma.wallenc.domain.datatypes.TextSecretEntryRecord
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncJournalEntry
import com.github.nullptroma.wallenc.domain.datatypes.StorageSyncLock
import com.github.nullptroma.wallenc.domain.interfaces.IDirectory
import com.github.nullptroma.wallenc.domain.interfaces.IFile
import com.github.nullptroma.wallenc.domain.interfaces.IStorage
import com.github.nullptroma.wallenc.domain.interfaces.IStorageAccessor
import com.github.nullptroma.wallenc.domain.interfaces.IStorageMetaInfo
import com.github.nullptroma.wallenc.domain.tasks.TaskProgress
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharedFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.emptyFlow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue
import org.junit.Test
import java.io.ByteArrayInputStream
import java.io.ByteArrayOutputStream
import java.io.InputStream
import java.io.OutputStream
import java.time.Instant
import java.util.UUID
class StorageDomainUseCasesTest {
@Test
fun twoFaCrudWorksAndPersists() = runBlocking {
val storage = FakeStorage()
val useCase = ManageTwoFaTokensUseCase()
val created = useCase.create(
storageInfo = storage,
issuer = "GitHub",
account = "user@example.com",
secret = "SECRET",
notes = "primary",
)
assertNotNull(created.id)
assertEquals(1, useCase.list(storage).size)
val updated = useCase.update(
storageInfo = storage,
token = created.copy(issuer = "GitHubUpdated"),
)
assertTrue(updated)
assertEquals("GitHubUpdated", useCase.get(storage, created.id)?.issuer)
val removed = useCase.delete(storage, created.id)
assertTrue(removed)
assertTrue(useCase.list(storage).isEmpty())
}
@Test
fun twoFaInvalidJsonFallsBackToEmptyList() = runBlocking {
val storage = FakeStorage().apply {
setDomainFile(StorageDomainDataFiles.TWO_FA_TOKENS_FILE, "not-json")
}
val useCase = ManageTwoFaTokensUseCase()
assertTrue(useCase.list(storage).isEmpty())
}
@Test
fun textSecretsCrudWorksWithOptionalLabels() = runBlocking {
val storage = FakeStorage()
val useCase = ManageTextSecretsUseCase()
val created = useCase.create(
storageInfo = storage,
title = "Server Credentials",
items = listOf(
TextSecretEntryRecord(label = "username", value = "admin"),
TextSecretEntryRecord(label = null, value = "password"),
),
)
assertEquals(1, useCase.list(storage).size)
val updated = useCase.update(
storageInfo = storage,
secret = created.copy(
title = "Prod Credentials",
items = created.items + TextSecretEntryRecord(label = "url", value = "https://x.dev"),
),
)
assertTrue(updated)
val loaded = useCase.get(storage, created.id)
assertEquals("Prod Credentials", loaded?.title)
assertEquals(3, loaded?.items?.size)
val deleted = useCase.delete(storage, created.id)
assertTrue(deleted)
assertNull(useCase.get(storage, created.id))
}
@Test
fun textSecretsInvalidJsonFallsBackToEmptyList() = runBlocking {
val storage = FakeStorage().apply {
setDomainFile(StorageDomainDataFiles.TEXT_SECRETS_FILE, "{broken")
}
val useCase = ManageTextSecretsUseCase()
assertTrue(useCase.list(storage).isEmpty())
}
}
private class FakeStorage : IStorage {
private val accessorImpl = FakeStorageAccessor()
override val uuid: UUID = UUID.randomUUID()
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isEmpty: Flow<Boolean?> = flowOf(true)
override val metaInfo: StateFlow<IStorageMetaInfo> = MutableStateFlow(FakeMetaInfo())
override val isVirtualStorage: Boolean = false
override val accessor: IStorageAccessor = accessorImpl
override suspend fun rename(newName: String) = Unit
override suspend fun setEncInfo(encInfo: StorageEncryptionInfo?) = Unit
override suspend fun clearAllContent(onProgress: suspend (TaskProgress) -> Unit) = Unit
fun setDomainFile(path: String, value: String) {
accessorImpl.dataFiles[path] = value.encodeToByteArray()
}
}
private class FakeMetaInfo : IStorageMetaInfo {
override val encInfo: StorageEncryptionInfo? = null
override val name: String? = "Fake"
override val lastModified: Instant = Instant.now()
}
private class FakeStorageAccessor : IStorageAccessor {
val dataFiles: MutableMap<String, ByteArray> = mutableMapOf()
private val systemFiles: MutableMap<String, ByteArray> = mutableMapOf()
override val size: StateFlow<Long?> = MutableStateFlow(0L)
override val numberOfFiles: StateFlow<Int?> = MutableStateFlow(0)
override val isAvailable: StateFlow<Boolean> = MutableStateFlow(true)
override val filesUpdates: SharedFlow<DataPage<IFile>> = MutableSharedFlow()
override val dirsUpdates: SharedFlow<DataPage<IDirectory>> = MutableSharedFlow()
override suspend fun getAllFiles(): List<IFile> = emptyList()
override suspend fun getFiles(path: String): List<IFile> = emptyList()
override fun getFilesFlow(path: String): Flow<DataPage<IFile>> = emptyFlow()
override suspend fun getAllDirs(): List<IDirectory> = emptyList()
override suspend fun getDirs(path: String): List<IDirectory> = emptyList()
override fun getDirsFlow(path: String): Flow<DataPage<IDirectory>> = emptyFlow()
override suspend fun getFileInfo(path: String): IFile {
error("Not implemented in tests")
}
override suspend fun getDirInfo(path: String): IDirectory {
error("Not implemented in tests")
}
override suspend fun setHidden(path: String, hidden: Boolean) = Unit
override suspend fun touchFile(path: String) = Unit
override suspend fun touchDir(path: String) = Unit
override suspend fun delete(path: String) = Unit
override suspend fun openWrite(path: String): OutputStream {
return object : ByteArrayOutputStream() {
override fun close() {
dataFiles[path] = toByteArray()
}
}
}
override suspend fun openRead(path: String): InputStream {
val bytes = dataFiles[path] ?: throw IllegalStateException("File not found: $path")
return ByteArrayInputStream(bytes)
}
override suspend fun moveToTrash(path: String) = Unit
override suspend fun openReadSystemFile(name: String): InputStream {
val bytes = systemFiles[name] ?: ByteArray(0)
return ByteArrayInputStream(bytes)
}
override suspend fun openWriteSystemFile(name: String): OutputStream {
return object : ByteArrayOutputStream() {
override fun close() {
systemFiles[name] = toByteArray()
}
}
}
override suspend fun readSyncJournal(): List<StorageSyncJournalEntry> = emptyList()
override suspend fun appendSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
override suspend fun rewriteSyncJournal(entries: List<StorageSyncJournalEntry>) = Unit
override suspend fun readSyncLock(): StorageSyncLock? = null
override suspend fun tryAcquireSyncLock(holderId: String, leaseUntil: Instant): Boolean = true
override suspend fun releaseSyncLock(holderId: String) = Unit
override suspend fun forceClearSyncLock() = Unit
}