421 lines
13 KiB
Rust
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(())
|
|
}
|