Extend desktop SQLite layer: per-app permissions, history queries, relays, settings
SqliteBunkerPermissionStore gains permissionsFor()/deletePermission() so the upcoming app-detail screen can list and revoke a single rule instead of only all-or-nothing via revokeAll(). SqliteBunkerHistoryLogger gains recentHistory()/recentHistoryFor()/nameFor(), and its applications upsert no longer clobbers a previously-learned app name with a blank one from a later request. New RelayStore/SettingsStore back a custom relay list and a small key/value UI-preferences store (theme mode), backed by two new tables in BunkerDatabase. All covered by SqliteBunkerDataLayerTest against an in-memory SQLite connection.
This commit is contained in:
@@ -25,6 +25,7 @@ dependencies {
|
||||
implementation(libs.xerial.sqlite.jdbc)
|
||||
|
||||
testImplementation(kotlin("test"))
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
}
|
||||
|
||||
compose.desktop {
|
||||
|
||||
@@ -63,6 +63,8 @@ object BunkerDatabase {
|
||||
)
|
||||
""".trimIndent(),
|
||||
)
|
||||
statement.executeUpdate("CREATE TABLE IF NOT EXISTS relays (url TEXT PRIMARY KEY)")
|
||||
statement.executeUpdate("CREATE TABLE IF NOT EXISTS settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||
}
|
||||
return connection
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
package com.greenart7c3.nostrsigner.desktop.data
|
||||
|
||||
import java.sql.Connection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Persists the user's custom relay list; [BunkerRelayConnection][com.greenart7c3.nostrsigner.desktop.relay.BunkerRelayConnection] falls back to its defaults when this is empty. */
|
||||
class RelayStore(private val connection: Connection) {
|
||||
suspend fun list(): List<String> = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement("SELECT url FROM relays ORDER BY url").use { statement ->
|
||||
statement.executeQuery().use { rows ->
|
||||
buildList { while (rows.next()) add(rows.getString("url")) }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun add(url: String) = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement("INSERT OR IGNORE INTO relays (url) VALUES (?)").use { statement ->
|
||||
statement.setString(1, url)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun remove(url: String) = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement("DELETE FROM relays WHERE url = ?").use { statement ->
|
||||
statement.setString(1, url)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
package com.greenart7c3.nostrsigner.desktop.data
|
||||
|
||||
import java.sql.Connection
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
/** Small key/value store for UI preferences (currently just theme mode). */
|
||||
class SettingsStore(private val connection: Connection) {
|
||||
suspend fun get(key: String): String? = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement("SELECT value FROM settings WHERE key = ?").use { statement ->
|
||||
statement.setString(1, key)
|
||||
statement.executeQuery().use { rows -> if (rows.next()) rows.getString("value") else null }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun set(key: String, value: String) = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO settings (key, value) VALUES (?, ?)
|
||||
ON CONFLICT(key) DO UPDATE SET value = excluded.value
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.setString(1, key)
|
||||
statement.setString(2, value)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
+96
-3
@@ -46,10 +46,51 @@ class SqliteBunkerPermissionStore(private val connection: Connection) : BunkerPe
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
/** Lists every stored auto-accept/reject rule for one app, for the app-detail permission editor. */
|
||||
suspend fun permissionsFor(appPubKey: String): List<StoredPermission> = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement(
|
||||
"SELECT method, kind, approved FROM permissions WHERE app_pub_key = ? ORDER BY method, kind",
|
||||
).use { statement ->
|
||||
statement.setString(1, appPubKey)
|
||||
statement.executeQuery().use { rows ->
|
||||
buildList {
|
||||
while (rows.next()) {
|
||||
add(
|
||||
StoredPermission(
|
||||
appPubKey = appPubKey,
|
||||
method = BunkerMethod.valueOf(rows.getString("method")),
|
||||
kind = BunkerDatabase.columnToKind(rows.getInt("kind")),
|
||||
approved = rows.getInt("approved") != 0,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Deletes a single stored rule, resetting that method/kind back to "ask next time". */
|
||||
suspend fun deletePermission(appPubKey: String, method: BunkerMethod, kind: Int?) = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement(
|
||||
"DELETE FROM permissions WHERE app_pub_key = ? AND method = ? AND kind = ?",
|
||||
).use { statement ->
|
||||
statement.setString(1, appPubKey)
|
||||
statement.setString(2, method.name)
|
||||
statement.setInt(3, BunkerDatabase.kindToColumn(kind))
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** A single stored auto-accept/auto-reject rule, as shown in the app-detail permission editor. */
|
||||
data class StoredPermission(val appPubKey: String, val method: BunkerMethod, val kind: Int?, val approved: Boolean)
|
||||
|
||||
data class ConnectedApp(val pubKey: String, val name: String, val connectedAt: Long)
|
||||
|
||||
/** One row of stored history, including its autoincrement id (used as a stable LazyColumn key). */
|
||||
data class HistoryRow(val id: Long, val entry: BunkerHistoryEntry)
|
||||
|
||||
class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHistoryLogger {
|
||||
override suspend fun log(entry: BunkerHistoryEntry) {
|
||||
withContext(Dispatchers.IO) {
|
||||
@@ -65,12 +106,15 @@ class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHist
|
||||
}
|
||||
connection.prepareStatement(
|
||||
"""
|
||||
INSERT INTO applications (app_pub_key, connected_at) VALUES (?, ?)
|
||||
ON CONFLICT(app_pub_key) DO UPDATE SET connected_at = excluded.connected_at
|
||||
INSERT INTO applications (app_pub_key, name, connected_at) VALUES (?, ?, ?)
|
||||
ON CONFLICT(app_pub_key) DO UPDATE SET
|
||||
connected_at = excluded.connected_at,
|
||||
name = CASE WHEN excluded.name != '' THEN excluded.name ELSE applications.name END
|
||||
""".trimIndent(),
|
||||
).use { statement ->
|
||||
statement.setString(1, entry.appPubKey)
|
||||
statement.setLong(2, entry.time)
|
||||
statement.setString(2, entry.appName.orEmpty())
|
||||
statement.setLong(3, entry.time)
|
||||
statement.executeUpdate()
|
||||
}
|
||||
}
|
||||
@@ -87,4 +131,53 @@ class SqliteBunkerHistoryLogger(private val connection: Connection) : BunkerHist
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The last known display name for an app, if any — used as [com.greenart7c3.nostrsigner.shared.BunkerSigningEngine]'s `appNameLookup` fallback. */
|
||||
suspend fun nameFor(appPubKey: String): String? = withContext(Dispatchers.IO) {
|
||||
connection.prepareStatement("SELECT name FROM applications WHERE app_pub_key = ?").use { statement ->
|
||||
statement.setString(1, appPubKey)
|
||||
statement.executeQuery().use { rows ->
|
||||
if (rows.next()) rows.getString("name").takeIf { it.isNotBlank() } else null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** The most recent history entries across all apps, newest first. */
|
||||
suspend fun recentHistory(limit: Int = 200): List<HistoryRow> = withContext(Dispatchers.IO) {
|
||||
queryHistory("SELECT id, app_pub_key, method, kind, approved, time FROM history ORDER BY time DESC LIMIT ?") { statement ->
|
||||
statement.setInt(1, limit)
|
||||
}
|
||||
}
|
||||
|
||||
/** The most recent history entries for a single app, newest first. */
|
||||
suspend fun recentHistoryFor(appPubKey: String, limit: Int = 50): List<HistoryRow> = withContext(Dispatchers.IO) {
|
||||
queryHistory(
|
||||
"SELECT id, app_pub_key, method, kind, approved, time FROM history WHERE app_pub_key = ? ORDER BY time DESC LIMIT ?",
|
||||
) { statement ->
|
||||
statement.setString(1, appPubKey)
|
||||
statement.setInt(2, limit)
|
||||
}
|
||||
}
|
||||
|
||||
private fun queryHistory(sql: String, bind: (java.sql.PreparedStatement) -> Unit): List<HistoryRow> = connection.prepareStatement(sql).use { statement ->
|
||||
bind(statement)
|
||||
statement.executeQuery().use { rows ->
|
||||
buildList {
|
||||
while (rows.next()) {
|
||||
add(
|
||||
HistoryRow(
|
||||
id = rows.getLong("id"),
|
||||
entry = BunkerHistoryEntry(
|
||||
appPubKey = rows.getString("app_pub_key"),
|
||||
method = BunkerMethod.valueOf(rows.getString("method")),
|
||||
kind = BunkerDatabase.columnToKind(rows.getInt("kind")),
|
||||
approved = rows.getInt("approved") != 0,
|
||||
time = rows.getLong("time"),
|
||||
),
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -0,0 +1,106 @@
|
||||
package com.greenart7c3.nostrsigner.desktop.data
|
||||
|
||||
import com.greenart7c3.nostrsigner.shared.BunkerHistoryEntry
|
||||
import com.greenart7c3.nostrsigner.shared.BunkerMethod
|
||||
import java.sql.Connection
|
||||
import java.sql.DriverManager
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.BeforeTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
||||
class SqliteBunkerDataLayerTest {
|
||||
private lateinit var connection: Connection
|
||||
|
||||
@BeforeTest
|
||||
fun setUp() {
|
||||
connection = DriverManager.getConnection("jdbc:sqlite::memory:")
|
||||
connection.createStatement().use { statement ->
|
||||
statement.executeUpdate("CREATE TABLE applications (app_pub_key TEXT PRIMARY KEY, name TEXT NOT NULL DEFAULT '', connected_at INTEGER NOT NULL)")
|
||||
statement.executeUpdate("CREATE TABLE permissions (app_pub_key TEXT NOT NULL, method TEXT NOT NULL, kind INTEGER NOT NULL, approved INTEGER NOT NULL, PRIMARY KEY (app_pub_key, method, kind))")
|
||||
statement.executeUpdate("CREATE TABLE history (id INTEGER PRIMARY KEY AUTOINCREMENT, app_pub_key TEXT NOT NULL, method TEXT NOT NULL, kind INTEGER NOT NULL, approved INTEGER NOT NULL, time INTEGER NOT NULL)")
|
||||
statement.executeUpdate("CREATE TABLE relays (url TEXT PRIMARY KEY)")
|
||||
statement.executeUpdate("CREATE TABLE settings (key TEXT PRIMARY KEY, value TEXT NOT NULL)")
|
||||
}
|
||||
}
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
connection.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun permissionsForListsAndDeletePermissionRemovesOne() = runTest {
|
||||
val store = SqliteBunkerPermissionStore(connection)
|
||||
store.remember("app1", BunkerMethod.SIGN_EVENT, 1, true)
|
||||
store.remember("app1", BunkerMethod.PING, null, false)
|
||||
|
||||
val permissions = store.permissionsFor("app1")
|
||||
assertEquals(2, permissions.size)
|
||||
assertTrue(permissions.any { it.method == BunkerMethod.SIGN_EVENT && it.kind == 1 && it.approved })
|
||||
assertTrue(permissions.any { it.method == BunkerMethod.PING && it.kind == null && !it.approved })
|
||||
|
||||
store.deletePermission("app1", BunkerMethod.PING, null)
|
||||
assertEquals(1, store.permissionsFor("app1").size)
|
||||
assertNull(store.isApproved("app1", BunkerMethod.PING, null))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun historyLoggerUpsertsAppNameWithoutClobberingWithBlank() = runTest {
|
||||
val logger = SqliteBunkerHistoryLogger(connection)
|
||||
logger.log(BunkerHistoryEntry("app1", BunkerMethod.CONNECT, null, true, 1000L, appName = "My App"))
|
||||
assertEquals("My App", logger.nameFor("app1"))
|
||||
|
||||
// A later request without metadata must not blank out the previously-learned name.
|
||||
logger.log(BunkerHistoryEntry("app1", BunkerMethod.PING, null, true, 2000L, appName = null))
|
||||
assertEquals("My App", logger.nameFor("app1"))
|
||||
|
||||
val connected = logger.connectedApps()
|
||||
assertEquals(1, connected.size)
|
||||
assertEquals("My App", connected.single().name)
|
||||
assertEquals(2000L, connected.single().connectedAt)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun recentHistoryAndRecentHistoryForReturnNewestFirst() = runTest {
|
||||
val logger = SqliteBunkerHistoryLogger(connection)
|
||||
logger.log(BunkerHistoryEntry("app1", BunkerMethod.PING, null, true, 1000L))
|
||||
logger.log(BunkerHistoryEntry("app2", BunkerMethod.GET_PUBLIC_KEY, null, true, 2000L))
|
||||
logger.log(BunkerHistoryEntry("app1", BunkerMethod.SIGN_EVENT, 1, true, 3000L))
|
||||
|
||||
val all = logger.recentHistory()
|
||||
assertEquals(listOf(3000L, 2000L, 1000L), all.map { it.entry.time })
|
||||
|
||||
val app1Only = logger.recentHistoryFor("app1")
|
||||
assertEquals(listOf(3000L, 1000L), app1Only.map { it.entry.time })
|
||||
assertTrue(app1Only.all { it.entry.appPubKey == "app1" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun relayStoreAddsListsAndRemoves() = runTest {
|
||||
val relayStore = RelayStore(connection)
|
||||
relayStore.add("wss://relay.example.com")
|
||||
relayStore.add("wss://relay2.example.com")
|
||||
relayStore.add("wss://relay.example.com") // duplicate, ignored
|
||||
|
||||
assertEquals(listOf("wss://relay.example.com", "wss://relay2.example.com"), relayStore.list())
|
||||
|
||||
relayStore.remove("wss://relay.example.com")
|
||||
assertEquals(listOf("wss://relay2.example.com"), relayStore.list())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun settingsStoreRoundTrips() = runTest {
|
||||
val settingsStore = SettingsStore(connection)
|
||||
assertNull(settingsStore.get("theme_mode"))
|
||||
|
||||
settingsStore.set("theme_mode", "DARK")
|
||||
assertEquals("DARK", settingsStore.get("theme_mode"))
|
||||
|
||||
settingsStore.set("theme_mode", "LIGHT")
|
||||
assertEquals("LIGHT", settingsStore.get("theme_mode"))
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user