/* This file is part of DarkFi (https://dark.fi)
*
* Copyright (C) 2020-2026 Dyne.org foundation
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details.
*
* You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see .
*/
use std::{path::PathBuf, sync::Arc};
use rand::{rngs::OsRng, Rng};
use smol::lock::Mutex as AsyncMutex;
use turso::{Connection, Value};
use crate::{
app::schema::menu::{channel::Channel, contact::Contact},
error::{Error, Result},
};
pub type AppDbPtr = Arc;
const APP_VERSION_KEY: &str = "app_version";
/// Single turso SQL database owning all app persistent state: channels,
/// contacts, darkirc identity, settings, and app flags. The kvdb-overlay
/// database remains exclusively for the event graph and chat history trees.
pub struct AppDb {
conn: AsyncMutex,
}
impl AppDb {
pub async fn new(path: &str) -> Result {
let db = turso::Builder::new_local(path).build().await.map_err(Error::from)?;
let conn = db.connect().map_err(Error::from)?;
let self_ = Arc::new(Self { conn: AsyncMutex::new(conn) });
self_.init_schema().await?;
Ok(self_)
}
async fn init_schema(&self) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute_batch(include_str!("../app.sql")).await?;
// First run: generate the DM identity secret right away. On
// subsequent runs the row exists and the insert is ignored.
let secret: [u8; 32] = OsRng.gen();
conn.execute(
"INSERT OR IGNORE INTO profiles (id, nick, dm_secret) VALUES (1, 'anon', ?1)",
vec![Value::Blob(secret.to_vec())],
)
.await?;
Ok(())
}
pub async fn channels(&self) -> Result> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT name, secret FROM channels ORDER BY name").await?;
let mut rows = stmt.query(()).await?;
let mut out = vec![];
while let Some(row) = rows.next().await? {
let name = row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string();
let secret = match row.get_value(1)? {
Value::Blob(b) if b.len() == 32 => Some(b.as_slice().try_into().unwrap()),
_ => None,
};
out.push(Channel { name, secret });
}
Ok(out)
}
pub async fn channel_get(&self, name: &str) -> Result> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT name, secret FROM channels WHERE name = ?1").await?;
let mut rows = stmt.query(vec![Value::Text(name.to_string())]).await?;
let Some(row) = rows.next().await? else { return Ok(None) };
let name = row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string();
let secret = match row.get_value(1)? {
Value::Blob(b) if b.len() == 32 => Some(b.as_slice().try_into().unwrap()),
_ => None,
};
Ok(Some(Channel { name, secret }))
}
pub async fn channel_insert(&self, channel: &Channel) -> Result<()> {
let conn = self.conn.lock().await;
let secret = channel.secret.map(|s| Value::Blob(s.to_vec())).unwrap_or(Value::Null);
conn.execute(
"INSERT OR REPLACE INTO channels (name, secret) VALUES (?1, ?2)",
vec![Value::Text(channel.name.clone()), secret],
)
.await?;
Ok(())
}
pub async fn contacts(&self) -> Result> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT name, public FROM contacts ORDER BY name").await?;
let mut rows = stmt.query(()).await?;
let mut out = vec![];
while let Some(row) = rows.next().await? {
let name = row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string();
let public =
row.get_value(1)?.as_blob().ok_or(Error::TursoErr)?.as_slice().try_into().unwrap();
out.push(Contact { name, public });
}
Ok(out)
}
pub async fn contact_get(&self, name: &str) -> Result> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT name, public FROM contacts WHERE name = ?1").await?;
let mut rows = stmt.query(vec![Value::Text(name.to_string())]).await?;
let Some(row) = rows.next().await? else { return Ok(None) };
let name = row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string();
let public =
row.get_value(1)?.as_blob().ok_or(Error::TursoErr)?.as_slice().try_into().unwrap();
Ok(Some(Contact { name, public }))
}
pub async fn contact_insert(&self, contact: &Contact) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT OR REPLACE INTO contacts (name, public) VALUES (?1, ?2)",
vec![Value::Text(contact.name.clone()), Value::Blob(contact.public.to_vec())],
)
.await?;
Ok(())
}
pub async fn nick_get(&self) -> Result > {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT nick FROM profiles WHERE id = 1").await?;
let mut rows = stmt.query(()).await?;
match rows.next().await? {
Some(row) => Ok(Some(row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string())),
None => Ok(None),
}
}
pub async fn nick_set(&self, nick: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"UPDATE profiles SET nick = ?1 WHERE id = 1",
vec![Value::Text(nick.to_string())],
)
.await?;
Ok(())
}
/// The identity row is created with a fresh random secret during
/// schema init, so this only reads.
pub async fn dm_secret(&self) -> Result<[u8; 32]> {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT dm_secret FROM profiles WHERE id = 1").await?;
let mut rows = stmt.query(()).await?;
let Some(row) = rows.next().await? else { return Err(Error::TursoErr) };
let Value::Blob(b) = row.get_value(0)? else { return Err(Error::TursoErr) };
if b.len() != 32 {
return Err(Error::TursoErr)
}
Ok(b.as_slice().try_into().unwrap())
}
/// Load all settings rows as (name, idx, value bytes).
pub async fn settings_all(&self) -> Result)>> {
let conn = self.conn.lock().await;
let mut stmt =
conn.prepare("SELECT name, idx, value FROM settings ORDER BY name, idx").await?;
let mut rows = stmt.query(()).await?;
let mut out = vec![];
while let Some(row) = rows.next().await? {
let name = row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string();
let idx = *row.get_value(1)?.as_integer().ok_or(Error::TursoErr)?;
let value = row.get_value(2)?.as_blob().ok_or(Error::TursoErr)?.to_vec();
out.push((name, idx as u32, value));
}
Ok(out)
}
pub async fn setting_get(&self, name: &str, idx: u32) -> Result>> {
let conn = self.conn.lock().await;
let mut stmt =
conn.prepare("SELECT value FROM settings WHERE name = ?1 AND idx = ?2").await?;
let mut rows =
stmt.query(vec![Value::Text(name.to_string()), Value::Integer(idx as i64)]).await?;
match rows.next().await? {
Some(row) => Ok(Some(row.get_value(0)?.as_blob().ok_or(Error::TursoErr)?.to_vec())),
None => Ok(None),
}
}
pub async fn setting_put(&self, name: &str, idx: u32, typ: &str, value: &[u8]) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT OR REPLACE INTO settings (name, idx, type, value) VALUES (?1, ?2, ?3, ?4)",
vec![
Value::Text(name.to_string()),
Value::Integer(idx as i64),
Value::Text(typ.to_string()),
Value::Blob(value.to_vec()),
],
)
.await?;
Ok(())
}
pub async fn setting_remove_idx(&self, name: &str, idx: u32) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"DELETE FROM settings WHERE name = ?1 AND idx = ?2",
vec![Value::Text(name.to_string()), Value::Integer(idx as i64)],
)
.await?;
Ok(())
}
/// Semver version of the app build that last ran, or `None` on a
/// fresh database.
pub async fn app_version_get(&self) -> Result > {
let conn = self.conn.lock().await;
let mut stmt = conn.prepare("SELECT value FROM flags WHERE name = ?1").await?;
let mut rows = stmt.query(vec![Value::Text(APP_VERSION_KEY.to_string())]).await?;
match rows.next().await? {
Some(row) => Ok(Some(row.get_value(0)?.as_text().ok_or(Error::TursoErr)?.to_string())),
None => Ok(None),
}
}
pub async fn app_version_set(&self, version: &str) -> Result<()> {
let conn = self.conn.lock().await;
conn.execute(
"INSERT OR REPLACE INTO flags (name, value) VALUES (?1, ?2)",
vec![Value::Text(APP_VERSION_KEY.to_string()), Value::Text(version.to_string())],
)
.await?;
Ok(())
}
}
#[cfg(target_os = "android")]
pub fn get_app_db_path() -> PathBuf {
crate::android::get_appdata_path().join("app.db")
}
#[cfg(not(target_os = "android"))]
pub fn get_app_db_path() -> PathBuf {
dirs::data_local_dir().unwrap().join("darkfi/app/app.db")
}
#[cfg(test)]
mod tests {
use super::*;
use crypto_box::SecretKey;
fn temp_db_path(tag: &str) -> String {
let path = std::env::temp_dir()
.join(format!("darkfi-app-db-test-{tag}-{}.db", std::process::id()));
let _ = std::fs::remove_file(&path);
path.to_str().unwrap().to_string()
}
/// Covers the app-storage spec: fresh start creates schema + seeds,
/// channels/contacts/settings/version roundtrip, identity (nick + DM
/// secret) survives a reopen.
#[test]
fn app_db_persistence() {
let path = temp_db_path("persist");
let seed_secret: [u8; 32] = core::array::from_fn(|i| i as u8);
let dm_public = {
let db = smol::block_on(AppDb::new(&path)).unwrap();
assert!(smol::block_on(db.channels()).unwrap().is_empty());
smol::block_on(db.channel_insert(&Channel { name: "dev".into(), secret: None }))
.unwrap();
smol::block_on(db.channel_insert(&Channel {
name: "secret_chan".into(),
secret: Some(seed_secret),
}))
.unwrap();
smol::block_on(
db.contact_insert(&Contact { name: "alice".into(), public: seed_secret }),
)
.unwrap();
smol::block_on(db.nick_set("testnick")).unwrap();
smol::block_on(db.setting_put("net.localnet", 0, "bool", &[1])).unwrap();
smol::block_on(db.setting_put("net.localnet", 2, "bool", &[0])).unwrap();
assert_eq!(smol::block_on(db.app_version_get()).unwrap(), None);
smol::block_on(db.app_version_set(env!("CARGO_PKG_VERSION"))).unwrap();
let secret = smol::block_on(db.dm_secret()).unwrap();
SecretKey::from_bytes(secret).public_key().to_bytes()
};
{
let db = smol::block_on(AppDb::new(&path)).unwrap();
let channels = smol::block_on(db.channels()).unwrap();
assert_eq!(channels.len(), 2);
assert_eq!(channels[0].name, "dev");
assert_eq!(channels[0].secret, None);
assert_eq!(channels[1].name, "secret_chan");
assert_eq!(channels[1].secret, Some(seed_secret));
let chan = smol::block_on(db.channel_get("dev")).unwrap().unwrap();
assert_eq!(chan.name, "dev");
let contacts = smol::block_on(db.contacts()).unwrap();
assert_eq!(contacts.len(), 1);
assert_eq!(contacts[0].name, "alice");
assert_eq!(contacts[0].public, seed_secret);
assert_eq!(smol::block_on(db.nick_get()).unwrap(), Some("testnick".into()));
assert_eq!(smol::block_on(db.setting_get("net.localnet", 0)).unwrap(), Some(vec![1u8]));
assert_eq!(smol::block_on(db.setting_get("net.localnet", 2)).unwrap(), Some(vec![0u8]));
assert_eq!(smol::block_on(db.setting_get("net.localnet", 1)).unwrap(), None);
smol::block_on(db.setting_remove_idx("net.localnet", 2)).unwrap();
assert_eq!(smol::block_on(db.setting_get("net.localnet", 2)).unwrap(), None);
assert_eq!(smol::block_on(db.settings_all()).unwrap().len(), 1);
assert_eq!(
smol::block_on(db.app_version_get()).unwrap(),
Some(env!("CARGO_PKG_VERSION").to_string())
);
// DM identity must be stable across reopen
let secret = smol::block_on(db.dm_secret()).unwrap();
assert_eq!(SecretKey::from_bytes(secret).public_key().to_bytes(), dm_public);
}
let _ = std::fs::remove_file(&path);
}
/// Covers: no-nick start keeps the default row, first-run generates and
/// persists a fresh DM identity (nonzero).
#[test]
fn app_db_fresh_identity() {
let path = temp_db_path("fresh");
let db = smol::block_on(AppDb::new(&path)).unwrap();
assert_eq!(smol::block_on(db.nick_get()).unwrap(), Some("anon".into()));
let secret = smol::block_on(db.dm_secret()).unwrap();
assert!(secret.iter().any(|&x| x != 0));
let again = smol::block_on(db.dm_secret()).unwrap();
assert_eq!(secret, again);
let _ = std::fs::remove_file(&path);
}
}