Skip to content

Commit 8dda41a

Browse files
Garbage collect orphaned blobs on note deletion
When notes are permanently deleted or trash is emptied, find attachment hashes that are no longer referenced by any remaining note and clean them up: delete local files, remove blob_meta/blob_uploads entries, and delete encrypted blobs from Blossom server. Covers all three deletion paths: UI permanent delete, empty trash, and sync-received deletions. Blossom HTTP deletes run in background tasks to avoid blocking the UI or sync message loop.
1 parent d71b3a0 commit 8dda41a

5 files changed

Lines changed: 224 additions & 17 deletions

File tree

src-tauri/src/attachments.rs

Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,9 @@
11
use crate::error::AppError;
2+
use regex_lite::Regex;
3+
use rusqlite::{params, params_from_iter, types::Value, Connection};
24
use serde::Serialize;
35
use sha2::{Digest, Sha256};
6+
use std::collections::HashSet;
47
use std::fs;
58
use std::path::PathBuf;
69
use tauri::{AppHandle, Manager};
@@ -88,6 +91,124 @@ pub fn save_blob(app: &AppHandle, hash: &str, ext: &str, data: &[u8]) -> Result<
8891
Ok(())
8992
}
9093

94+
/// Delete a blob from the local attachments directory by its hash.
95+
/// Returns true if a file was deleted.
96+
pub fn delete_local_blob(app: &AppHandle, hash: &str) -> Result<bool, AppError> {
97+
let dir = attachments_dir(app)?;
98+
for ext in KNOWN_EXTENSIONS {
99+
let path = dir.join(format!("{hash}.{ext}"));
100+
if path.exists() {
101+
fs::remove_file(&path)?;
102+
return Ok(true);
103+
}
104+
}
105+
Ok(false)
106+
}
107+
108+
/// Extract attachment:// hashes from markdown content.
109+
pub fn extract_attachment_hashes(markdown: &str) -> Vec<String> {
110+
static RE: std::sync::LazyLock<Regex> =
111+
std::sync::LazyLock::new(|| Regex::new(r"attachment://([a-f0-9]{64})\.\w+").unwrap());
112+
RE.captures_iter(markdown)
113+
.map(|cap| cap[1].to_string())
114+
.collect()
115+
}
116+
117+
/// Find attachment hashes in the given notes that are not referenced by any other note.
118+
/// Must be called BEFORE the notes are deleted.
119+
pub fn find_orphaned_blob_hashes(conn: &Connection, note_ids: &[String]) -> Result<HashSet<String>, AppError> {
120+
if note_ids.is_empty() {
121+
return Ok(HashSet::new());
122+
}
123+
124+
// Collect all hashes referenced by notes being deleted
125+
let placeholders: String = note_ids.iter().map(|_| "?").collect::<Vec<_>>().join(",");
126+
let sql = format!("SELECT markdown FROM notes WHERE id IN ({})", placeholders);
127+
let mut stmt = conn.prepare(&sql)?;
128+
let id_params: Vec<Value> = note_ids.iter().map(|id| Value::from(id.clone())).collect();
129+
let rows = stmt.query_map(params_from_iter(id_params.iter()), |row| {
130+
row.get::<_, String>(0)
131+
})?;
132+
133+
let mut candidate_hashes = HashSet::new();
134+
for row in rows {
135+
for hash in extract_attachment_hashes(&row?) {
136+
candidate_hashes.insert(hash);
137+
}
138+
}
139+
140+
if candidate_hashes.is_empty() {
141+
return Ok(HashSet::new());
142+
}
143+
144+
// Check which hashes are still referenced by other notes using SQL
145+
// to avoid pulling all markdown into memory
146+
let excluded = format!("SELECT markdown FROM notes WHERE id NOT IN ({})", placeholders);
147+
let mut remaining_stmt = conn.prepare(&excluded)?;
148+
let excluded_params: Vec<Value> = note_ids.iter().map(|id| Value::from(id.clone())).collect();
149+
let remaining_rows = remaining_stmt.query_map(params_from_iter(excluded_params.iter()), |row| {
150+
row.get::<_, String>(0)
151+
})?;
152+
153+
for row in remaining_rows {
154+
for hash in extract_attachment_hashes(&row?) {
155+
candidate_hashes.remove(&hash);
156+
}
157+
if candidate_hashes.is_empty() {
158+
break; // Early exit — all candidates are still referenced
159+
}
160+
}
161+
162+
Ok(candidate_hashes)
163+
}
164+
165+
/// Clean up orphaned blobs: delete local files, remove blob_meta/blob_uploads entries.
166+
/// Returns (server_url, ciphertext_hash) pairs for Blossom server deletion.
167+
pub fn cleanup_orphaned_blobs(
168+
app: &AppHandle,
169+
conn: &Connection,
170+
orphaned_hashes: &HashSet<String>,
171+
) -> Vec<(String, String)> {
172+
if orphaned_hashes.is_empty() {
173+
return Vec::new();
174+
}
175+
176+
let mut blossom_deletions = Vec::new();
177+
178+
// Prepare statements once outside the loop
179+
let mut meta_stmt = conn
180+
.prepare("SELECT server_url, ciphertext_hash FROM blob_meta WHERE plaintext_hash = ?1")
181+
.ok();
182+
let mut del_meta_stmt = conn
183+
.prepare("DELETE FROM blob_meta WHERE plaintext_hash = ?1")
184+
.ok();
185+
let mut del_uploads_stmt = conn
186+
.prepare("DELETE FROM blob_uploads WHERE hash = ?1")
187+
.ok();
188+
189+
for hash in orphaned_hashes {
190+
if let Some(ref mut stmt) = meta_stmt {
191+
if let Ok(rows) = stmt.query_map(params![hash], |row| {
192+
Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
193+
}) {
194+
for row in rows.flatten() {
195+
blossom_deletions.push(row);
196+
}
197+
}
198+
}
199+
if let Some(ref mut stmt) = del_meta_stmt {
200+
let _ = stmt.execute(params![hash]);
201+
}
202+
if let Some(ref mut stmt) = del_uploads_stmt {
203+
let _ = stmt.execute(params![hash]);
204+
}
205+
let _ = delete_local_blob(app, hash);
206+
eprintln!("[blob-gc] cleaned up orphaned blob hash={}", &hash[..8.min(hash.len())]);
207+
}
208+
209+
blossom_deletions
210+
}
211+
91212
/// Read a blob from the attachments directory by its hash.
92213
/// Returns (bytes, extension).
93214
pub fn read_blob(app: &AppHandle, hash: &str) -> Result<Option<(Vec<u8>, String)>, AppError> {

src-tauri/src/blossom.rs

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,37 @@ pub async fn upload_and_rewrite_attachments(
163163
Ok(result)
164164
}
165165

166+
/// Delete a blob from a Blossom server by its hash.
167+
pub async fn delete_blob(
168+
client: &reqwest::Client,
169+
blossom_url: &str,
170+
hash: &str,
171+
keys: &Keys,
172+
) -> Result<(), AppError> {
173+
eprintln!("[blossom] deleting hash={} from {}", &hash[..8.min(hash.len())], blossom_url);
174+
let auth_header = sign_blossom_auth(keys, "delete", hash, blossom_url)?;
175+
let url = format!("{}/{}", blossom_url.trim_end_matches('/'), hash);
176+
177+
let resp = client
178+
.delete(&url)
179+
.header("Authorization", auth_header)
180+
.send()
181+
.await
182+
.map_err(|e| {
183+
eprintln!("[blossom] delete request failed: {e}");
184+
AppError::custom(format!("Blossom delete failed: {e}"))
185+
})?;
186+
187+
if !resp.status().is_success() && resp.status().as_u16() != 404 {
188+
let status = resp.status();
189+
eprintln!("[blossom] delete failed ({status}) for hash={}", &hash[..8.min(hash.len())]);
190+
return Err(AppError::custom(format!("Blossom delete failed ({status})")));
191+
}
192+
193+
eprintln!("[blossom] delete ok hash={}", &hash[..8.min(hash.len())]);
194+
Ok(())
195+
}
196+
166197
/// Download an encrypted blob from a Blossom server by its ciphertext hash.
167198
pub async fn download_blob(
168199
client: &reqwest::Client,

src-tauri/src/lib.rs

Lines changed: 41 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ mod themes;
99

1010
use db::database_connection;
1111
use error::AppError;
12+
use nostr_sdk::prelude::Keys;
1213
use rusqlite::OptionalExtension;
1314
use notes::{
1415
AssignNoteNotebookInput, BootstrapPayload, ContextualTagsInput, ContextualTagsPayload,
@@ -156,7 +157,8 @@ fn restore_from_trash(app: AppHandle, note_id: String) -> Result<LoadedNote, App
156157

157158
#[tauri::command]
158159
fn delete_note_permanently(app: AppHandle, note_id: String) -> Result<(), AppError> {
159-
notes::delete_note_permanently(&app, &note_id)?;
160+
let blossom_deletions = notes::delete_note_permanently(&app, &note_id)?;
161+
spawn_blossom_deletions(&app, blossom_deletions);
160162
// Always queue deletion — covers the race where sync pushes the note
161163
// between creation and deletion, and is a harmless no-op if never synced.
162164
let conn = database_connection(&app)?;
@@ -170,7 +172,8 @@ fn delete_note_permanently(app: AppHandle, note_id: String) -> Result<(), AppErr
170172

171173
#[tauri::command]
172174
fn empty_trash(app: AppHandle) -> Result<(), AppError> {
173-
let note_ids = notes::empty_trash(&app)?;
175+
let (note_ids, blossom_deletions) = notes::empty_trash(&app)?;
176+
spawn_blossom_deletions(&app, blossom_deletions);
174177
let conn = database_connection(&app)?;
175178
for note_id in &note_ids {
176179
let _ = conn.execute(
@@ -240,6 +243,42 @@ fn sync_push(app: &AppHandle, cmd: sync::SyncCommand) {
240243
});
241244
}
242245

246+
/// Spawn async Blossom blob deletions for orphaned blobs.
247+
/// blossom_deletions is a list of (server_url, ciphertext_hash) pairs.
248+
fn spawn_blossom_deletions(app: &AppHandle, blossom_deletions: Vec<(String, String)>) {
249+
if blossom_deletions.is_empty() {
250+
return;
251+
}
252+
253+
let conn = match database_connection(app) {
254+
Ok(c) => c,
255+
Err(_) => return,
256+
};
257+
let keys = match conn
258+
.query_row(
259+
"SELECT secret_key FROM nostr_identity LIMIT 1",
260+
[],
261+
|row| row.get::<_, String>(0),
262+
)
263+
.optional()
264+
{
265+
Ok(Some(secret_hex)) => match Keys::parse(&secret_hex) {
266+
Ok(k) => k,
267+
Err(_) => return,
268+
},
269+
_ => return,
270+
};
271+
272+
tauri::async_runtime::spawn(async move {
273+
let client = reqwest::Client::new();
274+
for (server_url, ciphertext_hash) in blossom_deletions {
275+
if let Err(e) = crate::blossom::delete_blob(&client, &server_url, &ciphertext_hash, &keys).await {
276+
eprintln!("[blob-gc] failed to delete from Blossom: {e}");
277+
}
278+
}
279+
});
280+
}
281+
243282
fn reset_sync_state(conn: &rusqlite::Connection) -> Result<(), AppError> {
244283
conn.execute_batch("BEGIN")?;
245284
let result = (|| -> Result<(), AppError> {

src-tauri/src/notes.rs

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
use crate::attachments::{cleanup_orphaned_blobs, find_orphaned_blob_hashes};
12
use crate::db::{database_connection, extract_tags};
23
use crate::error::{now_millis, AppError};
34
use crate::nostr;
@@ -400,44 +401,50 @@ pub fn restore_from_trash(app: &AppHandle, note_id: &str) -> Result<LoadedNote,
400401
note_by_id(&conn, note_id)?.ok_or_else(|| AppError::custom("Note not found."))
401402
}
402403

403-
pub fn delete_note_permanently(app: &AppHandle, note_id: &str) -> Result<(), AppError> {
404+
pub fn delete_note_permanently(app: &AppHandle, note_id: &str) -> Result<Vec<(String, String)>, AppError> {
404405
validate_note_id(note_id)?;
405406
let mut conn = database_connection(app)?;
406-
let transaction = conn.transaction()?;
407407

408+
let orphaned = find_orphaned_blob_hashes(&conn, &[note_id.to_string()])?;
409+
410+
let transaction = conn.transaction()?;
408411
delete_note_search_document(&transaction, note_id)?;
409412
let deleted = transaction
410413
.execute("DELETE FROM notes WHERE id = ?1", params![note_id])?;
411-
412414
if deleted == 0 {
413415
return Err(AppError::custom("Note not found."));
414416
}
415-
416417
transaction.commit()?;
417418

419+
let blossom_deletions = cleanup_orphaned_blobs(app, &conn, &orphaned);
420+
418421
if last_open_note_id(&conn)?.as_deref() == Some(note_id) {
419422
set_last_open_note_id(&conn, next_active_note_id(&conn, Some(note_id))?.as_deref())?;
420423
}
421424

422-
Ok(())
425+
Ok(blossom_deletions)
423426
}
424427

425-
pub fn empty_trash(app: &AppHandle) -> Result<Vec<String>, AppError> {
428+
pub fn empty_trash(app: &AppHandle) -> Result<(Vec<String>, Vec<(String, String)>), AppError> {
426429
let mut conn = database_connection(app)?;
427430
// Collect IDs of trashed notes for sync deletion
428431
let note_ids: Vec<String> = conn
429432
.prepare("SELECT id FROM notes WHERE deleted_at IS NOT NULL")?
430433
.query_map([], |row| row.get(0))?
431434
.collect::<Result<Vec<_>, _>>()?;
432435

436+
let orphaned = find_orphaned_blob_hashes(&conn, &note_ids)?;
437+
433438
let transaction = conn.transaction()?;
434439
transaction.execute_batch(
435440
"DELETE FROM notes_fts WHERE note_id IN (SELECT id FROM notes WHERE deleted_at IS NOT NULL);
436441
DELETE FROM notes WHERE deleted_at IS NOT NULL;",
437442
)?;
438443
transaction.commit()?;
439444

440-
Ok(note_ids)
445+
let blossom_deletions = cleanup_orphaned_blobs(app, &conn, &orphaned);
446+
447+
Ok((note_ids, blossom_deletions))
441448
}
442449

443450
pub fn create_notebook(

src-tauri/src/sync.rs

Lines changed: 17 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -349,14 +349,7 @@ fn gift_wrap_d_tag(secret_key: &SecretKey, note_id: &str) -> String {
349349
hex::encode(mac.finalize().into_bytes())
350350
}
351351

352-
/// Extract attachment:// hashes from markdown content.
353-
fn extract_attachment_hashes(markdown: &str) -> Vec<String> {
354-
static RE: std::sync::LazyLock<regex_lite::Regex> =
355-
std::sync::LazyLock::new(|| regex_lite::Regex::new(r"attachment://([a-f0-9]{64})\.\w+").unwrap());
356-
RE.captures_iter(markdown)
357-
.map(|cap| cap[1].to_string())
358-
.collect()
359-
}
352+
use crate::attachments::{cleanup_orphaned_blobs, extract_attachment_hashes, find_orphaned_blob_hashes};
360353

361354
fn note_to_rumor(
362355
note_id: &str,
@@ -959,9 +952,25 @@ async fn process_relay_message(
959952
// Delete notebook
960953
conn.execute("DELETE FROM notebooks WHERE id = ?1", params![d_tag])?;
961954
} else {
955+
// Collect orphaned blobs before deleting the note
956+
let orphaned = find_orphaned_blob_hashes(&conn, &[d_tag.clone()]).unwrap_or_default();
962957
// Permanently delete the note
963958
conn.execute("DELETE FROM notes_fts WHERE note_id = ?1", params![d_tag])?;
964959
conn.execute("DELETE FROM notes WHERE id = ?1", params![d_tag])?;
960+
// Clean up orphaned blobs (local + metadata)
961+
let blossom_deletions = cleanup_orphaned_blobs(app, &conn, &orphaned);
962+
// Spawn Blossom deletes in background to not block sync
963+
if !blossom_deletions.is_empty() {
964+
let keys = keys.clone();
965+
tokio::spawn(async move {
966+
let http_client = reqwest::Client::new();
967+
for (server_url, ciphertext_hash) in blossom_deletions {
968+
if let Err(e) = crate::blossom::delete_blob(&http_client, &server_url, &ciphertext_hash, &keys).await {
969+
eprintln!("[blob-gc] blossom delete failed: {e}");
970+
}
971+
}
972+
});
973+
}
965974
}
966975

967976
let _ = app.emit(

0 commit comments

Comments
 (0)