Files
sovereign_browser_rust/src/db.rs
T

421 lines
13 KiB
Rust

//! SQLite database layer for sovereign_browser
//!
//! Provides persistent storage for events, settings, bookmarks, history,
//! and other browser data. Uses rusqlite with bundled SQLite.
//!
//! Port of `db.c` / `db.h` from the C project.
use rusqlite::{Connection, params};
use std::path::PathBuf;
use std::sync::Mutex;
use once_cell::sync::Lazy;
/// Global database connection (thread-safe via Mutex).
static DB: Lazy<Mutex<Option<Connection>>> = Lazy::new(|| Mutex::new(None));
/// Get the database directory path.
fn db_dir() -> PathBuf {
let home = std::env::var("HOME").unwrap_or_else(|_| "/tmp".to_string());
PathBuf::from(home).join(".sovereign_browser")
}
/// Get the database path for a given profile pubkey.
fn db_path(pubkey_hex: &str) -> PathBuf {
db_dir().join("profiles").join(pubkey_hex).join("browser.db")
}
/// Initialize the database for a given profile.
pub fn db_init(pubkey_hex: &str) -> Result<(), Box<dyn std::error::Error>> {
let path = db_path(pubkey_hex);
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
let conn = Connection::open_with_flags(
&path,
rusqlite::OpenFlags::SQLITE_OPEN_READ_WRITE
| rusqlite::OpenFlags::SQLITE_OPEN_CREATE
| rusqlite::OpenFlags::SQLITE_OPEN_FULL_MUTEX,
)?;
conn.execute_batch("PRAGMA journal_mode=WAL; PRAGMA synchronous=NORMAL;")?;
// Create tables
conn.execute_batch(
"
CREATE TABLE IF NOT EXISTS events (
id TEXT PRIMARY KEY,
pubkey TEXT NOT NULL,
kind INTEGER NOT NULL,
created_at INTEGER NOT NULL,
content TEXT NOT NULL,
tags TEXT NOT NULL DEFAULT '[]',
sig TEXT,
relay_url TEXT,
fetched_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS kv_store (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE IF NOT EXISTS bookmarks (
id INTEGER PRIMARY KEY AUTOINCREMENT,
title TEXT NOT NULL,
url TEXT NOT NULL,
folder TEXT NOT NULL DEFAULT '',
position INTEGER NOT NULL DEFAULT 0,
created_at INTEGER NOT NULL,
updated_at INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS history (
id INTEGER PRIMARY KEY AUTOINCREMENT,
url TEXT NOT NULL,
title TEXT NOT NULL DEFAULT '',
visit_count INTEGER NOT NULL DEFAULT 1,
last_visit_at INTEGER NOT NULL,
created_at INTEGER NOT NULL
);
CREATE INDEX IF NOT EXISTS idx_events_pubkey ON events(pubkey);
CREATE INDEX IF NOT EXISTS idx_events_kind ON events(kind);
CREATE INDEX IF NOT EXISTS idx_events_created_at ON events(created_at);
CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
CREATE INDEX IF NOT EXISTS idx_history_last_visit ON history(last_visit_at);
",
)?;
let mut db = DB.lock().unwrap();
*db = Some(conn);
Ok(())
}
/// Close the database connection.
pub fn db_close() {
let mut db = DB.lock().unwrap();
*db = None;
}
/// Store a Nostr event in the database.
pub fn db_store_event(
id: &str,
pubkey: &str,
kind: u64,
created_at: u64,
content: &str,
tags: &str,
sig: Option<&str>,
relay_url: Option<&str>,
) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
conn.execute(
"INSERT OR REPLACE INTO events (id, pubkey, kind, created_at, content, tags, sig, relay_url, fetched_at)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
params![id, pubkey, kind, created_at, content, tags, sig, relay_url, now],
)?;
}
Ok(())
}
/// Get a Nostr event by ID.
pub fn db_get_event(id: &str) -> Result<Option<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE id = ?1",
)?;
let mut rows = stmt.query(params![id])?;
if let Some(row) = rows.next()? {
let tags_str: String = row.get(5)?;
let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![]));
let event = serde_json::json!({
"id": row.get::<_, String>(0)?,
"pubkey": row.get::<_, String>(1)?,
"kind": row.get::<_, u64>(2)?,
"created_at": row.get::<_, u64>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"sig": row.get::<_, Option<String>>(6)?,
"relay_url": row.get::<_, Option<String>>(7)?,
});
return Ok(Some(event));
}
}
Ok(None)
}
/// Query events by filter.
pub fn db_query_events(
kinds: &[u64],
authors: Option<&[String]>,
since: Option<u64>,
until: Option<u64>,
limit: Option<u64>,
) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut sql = String::from(
"SELECT id, pubkey, kind, created_at, content, tags, sig, relay_url FROM events WHERE 1=1",
);
if !kinds.is_empty() {
let placeholders: Vec<String> = kinds.iter().map(|_| "?".to_string()).collect();
sql.push_str(&format!(" AND kind IN ({})", placeholders.join(",")));
}
if authors.is_some() {
sql.push_str(" AND pubkey = ?");
}
if since.is_some() {
sql.push_str(" AND created_at >= ?");
}
if until.is_some() {
sql.push_str(" AND created_at <= ?");
}
sql.push_str(" ORDER BY created_at DESC");
if let Some(lim) = limit {
sql.push_str(&format!(" LIMIT {}", lim));
}
let mut stmt = conn.prepare(&sql)?;
let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
for k in kinds {
param_values.push(Box::new(*k));
}
if let Some(authors) = authors {
if let Some(a) = authors.first() {
param_values.push(Box::new(a.clone()));
}
}
if let Some(s) = since {
param_values.push(Box::new(s));
}
if let Some(u) = until {
param_values.push(Box::new(u));
}
let params_refs: Vec<&dyn rusqlite::types::ToSql> = param_values.iter().map(|p| p.as_ref()).collect();
let mut rows = stmt.query(params_refs.as_slice())?;
while let Some(row) = rows.next()? {
let tags_str: String = row.get(5)?;
let tags: serde_json::Value = serde_json::from_str(&tags_str).unwrap_or(serde_json::Value::Array(vec![]));
let event = serde_json::json!({
"id": row.get::<_, String>(0)?,
"pubkey": row.get::<_, String>(1)?,
"kind": row.get::<_, u64>(2)?,
"created_at": row.get::<_, u64>(3)?,
"content": row.get::<_, String>(4)?,
"tags": tags,
"sig": row.get::<_, Option<String>>(6)?,
"relay_url": row.get::<_, Option<String>>(7)?,
});
results.push(event);
}
}
Ok(results)
}
/// Store a key-value pair.
pub fn db_kv_set(key: &str, value: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"INSERT OR REPLACE INTO kv_store (key, value) VALUES (?1, ?2)",
params![key, value],
)?;
}
Ok(())
}
/// Get a key-value pair.
pub fn db_kv_get(key: &str) -> Result<Option<String>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare("SELECT value FROM kv_store WHERE key = ?1")?;
let mut rows = stmt.query(params![key])?;
if let Some(row) = rows.next()? {
return Ok(Some(row.get(0)?));
}
}
Ok(None)
}
/// Delete a key-value pair.
pub fn db_kv_delete(key: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM kv_store WHERE key = ?1", params![key])?;
}
Ok(())
}
/// Add a bookmark.
pub fn db_add_bookmark(
title: &str,
url: &str,
folder: &str,
) -> Result<i64, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
conn.execute(
"INSERT INTO bookmarks (title, url, folder, position, created_at, updated_at)
VALUES (?1, ?2, ?3, 0, ?4, ?4)",
params![title, url, folder, now],
)?;
return Ok(conn.last_insert_rowid());
}
Ok(0)
}
/// Get all bookmarks.
pub fn db_get_bookmarks() -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, title, url, folder, position, created_at, updated_at FROM bookmarks ORDER BY folder, position",
)?;
let mut rows = stmt.query([])?;
while let Some(row) = rows.next()? {
let bookmark = serde_json::json!({
"id": row.get::<_, i64>(0)?,
"title": row.get::<_, String>(1)?,
"url": row.get::<_, String>(2)?,
"folder": row.get::<_, String>(3)?,
"position": row.get::<_, i64>(4)?,
"created_at": row.get::<_, u64>(5)?,
"updated_at": row.get::<_, u64>(6)?,
});
results.push(bookmark);
}
}
Ok(results)
}
/// Delete a bookmark.
pub fn db_delete_bookmark(id: i64) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM bookmarks WHERE id = ?1", params![id])?;
}
Ok(())
}
/// Update a bookmark's title.
pub fn db_update_bookmark_title(id: i64, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"UPDATE bookmarks SET title = ?1, updated_at = ?2 WHERE id = ?3",
params![title, now_secs(), id],
)?;
}
Ok(())
}
/// Update a bookmark's folder path.
pub fn db_update_bookmark_folder(id: i64, folder: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute(
"UPDATE bookmarks SET folder = ?1, updated_at = ?2 WHERE id = ?3",
params![folder, now_secs(), id],
)?;
}
Ok(())
}
/// Current unix timestamp in seconds.
fn now_secs() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
}
/// Add a history entry.
pub fn db_add_history(url: &str, title: &str) -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs();
// Try to update existing entry
let updated = conn.execute(
"UPDATE history SET visit_count = visit_count + 1, last_visit_at = ?1, title = ?2 WHERE url = ?3",
params![now, title, url],
)?;
if updated == 0 {
conn.execute(
"INSERT INTO history (url, title, visit_count, last_visit_at, created_at)
VALUES (?1, ?2, 1, ?3, ?3)",
params![url, title, now],
)?;
}
}
Ok(())
}
/// Get history entries.
pub fn db_get_history(limit: u64) -> Result<Vec<serde_json::Value>, Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
let mut results = Vec::new();
if let Some(ref conn) = *db {
let mut stmt = conn.prepare(
"SELECT id, url, title, visit_count, last_visit_at, created_at
FROM history ORDER BY last_visit_at DESC LIMIT ?1",
)?;
let mut rows = stmt.query(params![limit])?;
while let Some(row) = rows.next()? {
let entry = serde_json::json!({
"id": row.get::<_, i64>(0)?,
"url": row.get::<_, String>(1)?,
"title": row.get::<_, String>(2)?,
"visit_count": row.get::<_, i64>(3)?,
"last_visit_at": row.get::<_, u64>(4)?,
"created_at": row.get::<_, u64>(5)?,
});
results.push(entry);
}
}
Ok(results)
}
/// Clear all history.
pub fn db_clear_history() -> Result<(), Box<dyn std::error::Error>> {
let db = DB.lock().unwrap();
if let Some(ref conn) = *db {
conn.execute("DELETE FROM history", [])?;
}
Ok(())
}