Skip to content

Commit 872f03a

Browse files
committed
fix: Prevent duplicates across magnets/torrents
1 parent af27102 commit 872f03a

3 files changed

Lines changed: 79 additions & 12 deletions

File tree

src/database/magnet.rs

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ use snafu::prelude::*;
66

77
use crate::database::operation::*;
88
use crate::database::operator::DatabaseOperator;
9+
use crate::database::torrent::TorrentError;
910
use crate::database::{category, content_folder};
1011
use crate::extractors::user::User;
1112
use crate::routes::magnet::MagnetForm;
@@ -53,6 +54,12 @@ pub enum MagnetError {
5354
NoSuchCategory { id: i32 },
5455
#[snafu(display("Requested content folder not found"))]
5556
NoSuchContentFolder { id: i32 },
57+
#[snafu(display("The magnet is already uploaded"))]
58+
DuplicateMagnet,
59+
#[snafu(display("This magnet is already known in the system with the full torrent"))]
60+
DuplicateTorrent,
61+
#[snafu(display("Failed to read the torrent list"))]
62+
Torrent { source: TorrentError },
5663
}
5764

5865
#[derive(Clone, Debug)]
@@ -200,7 +207,15 @@ impl MagnetOperator {
200207

201208
if list.iter().any(|x| x.torrent_id == magnet.id()) {
202209
// The magnet is already known
203-
return self.get_by_torrent_id(&magnet.id()).await;
210+
return Err(MagnetError::DuplicateMagnet);
211+
}
212+
213+
// Check for duplicates in the torrent table, so we don't
214+
// even have to resolve the magnet.
215+
let list = self.db().torrent().list().await.context(TorrentSnafu)?;
216+
if list.iter().any(|x| x.torrent_id == magnet.id()) {
217+
// The magnet is already known as a torrent
218+
return Err(MagnetError::DuplicateTorrent);
204219
}
205220

206221
// Verify that the requested category/content_folder exist
@@ -264,4 +279,14 @@ impl MagnetOperator {
264279

265280
Ok(model)
266281
}
282+
283+
/// Removes (and cancels resolution) for a given magnet,
284+
///
285+
/// if it was previously known. Has no effect otherwise.
286+
pub async fn cancel_and_remove(&self, torrent_id: &TorrentID) {
287+
if let Ok(magnet) = self.get_by_torrent_id(torrent_id).await {
288+
// TODO: should we error here?
289+
self.delete(magnet.id).await.unwrap();
290+
}
291+
}
267292
}

src/database/torrent.rs

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -53,6 +53,8 @@ pub enum TorrentError {
5353
NoSuchCategory { id: i32 },
5454
#[snafu(display("Requested content folder not found"))]
5555
NoSuchContentFolder { id: i32 },
56+
#[snafu(display("This torrent is already known"))]
57+
DuplicateTorrent,
5658
}
5759

5860
#[derive(Clone, Debug)]
@@ -203,10 +205,9 @@ impl TorrentOperator {
203205

204206
// Check duplicates
205207
let list = self.list().await?;
206-
207208
if list.iter().any(|x| x.torrent_id == torrent.id()) {
208209
// The torrent is already known
209-
return self.get_by_torrent_id(&torrent.id()).await;
210+
return Err(TorrentError::DuplicateTorrent);
210211
}
211212

212213
// Verify that the requested category/content_folder exist
@@ -240,6 +241,9 @@ impl TorrentOperator {
240241
.await
241242
.context(DBSnafu)?;
242243

244+
// Now if there was a magnet with this torrentID, we just remove it and cancel the task
245+
self.db().magnet().cancel_and_remove(&torrent.id()).await;
246+
243247
// Should not fail
244248
let model = model.try_into_model().unwrap();
245249

src/resolver.rs

Lines changed: 47 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,8 @@ use tokio::time::{Duration, sleep};
77
use std::collections::HashMap;
88
use std::sync::Arc;
99

10-
use crate::database::magnet::MagnetOperator;
10+
use crate::database::magnet;
11+
use crate::routes::torrent::TorrentForm;
1112
use crate::state::AppState;
1213

1314
/// A magnet link resolver
@@ -21,11 +22,10 @@ use crate::state::AppState;
2122
/// for updates on a channel determined on startup. This will
2223
/// avoid polluting the logs with queries to read the table.
2324
pub struct Resolver {
24-
operator: MagnetOperator,
25+
operator: magnet::MagnetOperator,
2526
// Channel to receive new magnets to resolve
2627
receiver: UnboundedReceiver<MagnetLink>,
2728
session: Arc<Session>,
28-
state: AppState,
2929
// Keep track of background tasks resolving torrent files from magnets
3030
// In the future, this will allow to cancel/delete tasks.
3131
tasks: HashMap<TorrentID, JoinHandle<TorrentFile>>,
@@ -46,13 +46,12 @@ impl Resolver {
4646
.unwrap();
4747

4848
Self {
49-
operator: MagnetOperator {
49+
operator: magnet::MagnetOperator {
5050
state: state.clone(),
5151
user: None,
5252
},
5353
receiver,
5454
session,
55-
state,
5655
tasks: HashMap::new(),
5756
}
5857
}
@@ -131,7 +130,18 @@ impl Resolver {
131130
}
132131
}
133132

134-
/// Check if some tasks have finished resolving, and save result in the DB.
133+
/// Check if some magnets have finished resolving, and save result in the DB.
134+
///
135+
/// In order to avoid losing data, we check in this order:
136+
///
137+
/// - if the magnet no longer exists, do nothing
138+
/// - if there is already a corresponding torrent, do nothing
139+
/// - save the corresponding torrent
140+
/// - remove the corresponding magnet if the saving was successful
141+
///
142+
/// On startup, we will further check that no dangling magnets are left due to
143+
/// the program crashing/stopping between saving the torrent and removing
144+
/// the magnet.
135145
pub async fn save_resolved(&mut self) {
136146
let finished_ids: Vec<TorrentID> = self
137147
.tasks
@@ -147,6 +157,22 @@ impl Resolver {
147157
for finished_id in finished_ids {
148158
log::info!("Magnet {finished_id} has finished resolving. Saving to DB.");
149159

160+
// First verify if someone has in the meantime added the fully resolved torrent?
161+
if let Ok(_torrent) = self
162+
.operator
163+
.db()
164+
.torrent()
165+
.get_by_torrent_id(&finished_id)
166+
.await
167+
{
168+
// Nothing to do, everything is already well and good.
169+
// Well actually, the imported category/folder for the torrent may be different from
170+
// the one requested on the magnet. But since we already have a duplicate check on
171+
// magnet/torrent upload and this case is only for TOCTOU race cases, ignoring
172+
// it seems reasonable.
173+
return;
174+
}
175+
150176
// Get the raw task handle, removing it from the active tasks
151177
let handle = self.tasks.remove(&finished_id).unwrap();
152178
let torrent_file = handle.await.unwrap();
@@ -164,10 +190,22 @@ impl Resolver {
164190
continue
165191
}
166192

167-
}
193+
// Now that we have saved the torrent state, move to the torrent table
194+
let torrent_form = TorrentForm {
195+
category_id: magnet.category_id,
196+
content_folder_id: magnet.content_folder_id,
197+
file: axum::body::Bytes::copy_from_slice(&torrent_file.to_vec()),
198+
};
199+
200+
if let Err(e) = self.operator.db().torrent().create(&torrent_form).await {
201+
// If the creation was not successful, log it!
202+
log::error!(
203+
"Failed to move finished magnet {finished_id} to the torrent table: {e}"
204+
);
205+
}
168206

169-
// TODO: save to the torrent DB
170-
log::info!("Torrent file for magnet {finished_id} has been saved to DB");
207+
log::info!("Torrent file for magnet {finished_id} has been saved to DB");
208+
}
171209
}
172210
}
173211
}

0 commit comments

Comments
 (0)