diff --git a/Cargo.lock b/Cargo.lock index 67795b2..61088bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -188,7 +188,7 @@ dependencies = [ "serde_json", "serde_urlencoded", "smallvec", - "socket2 0.6.2", + "socket2 0.6.3", "time", "tracing", "url", @@ -590,7 +590,7 @@ checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af" [[package]] name = "bittseeder" -version = "0.1.0" +version = "0.1.1" dependencies = [ "actix-web", "actix-web-httpauth", @@ -1773,7 +1773,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.2", + "socket2 0.6.3", "tokio", "tower-service", "tracing", @@ -2584,7 +2584,7 @@ dependencies = [ "quinn-udp", "rustc-hash", "rustls", - "socket2 0.6.2", + "socket2 0.6.3", "thiserror 2.0.18", "tokio", "tracing", @@ -2622,7 +2622,7 @@ dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.6.2", + "socket2 0.6.3", "tracing", "windows-sys 0.60.2", ] @@ -3319,12 +3319,12 @@ dependencies = [ [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -3544,7 +3544,7 @@ dependencies = [ "parking_lot", "pin-project-lite", "signal-hook-registry", - "socket2 0.6.2", + "socket2 0.6.3", "tokio-macros", "windows-sys 0.61.2", ] @@ -3803,9 +3803,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", diff --git a/Cargo.toml b/Cargo.toml index 4720815..942b549 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,7 +1,6 @@ - [package] name = "bittseeder" -version = "0.1.0" +version = "0.1.1" edition = "2024" license = "MIT" description = "Unified BT+RTC Seeder — Seeds over BitTorrent and/or WebRTC simultaneously." @@ -65,7 +64,7 @@ winres = "^0.1" name = "BittSeeder" identifier = "com.power2all.bittseeder" icon = ["icon.ico"] -version = "0.1.0" +version = "0.1.1" copyright = "Copyright (c) 2025 Jasper Lingers" category = "Public Utility" short_description = "Unified BT+RTC Seeder" diff --git a/README.md b/README.md index 09bd8b9..52fde62 100644 --- a/README.md +++ b/README.md @@ -2,12 +2,13 @@  []() []() +[](https://github.com/Power2All/bittseeder) A fast, unified Rust seeder that serves torrent data over **BitTorrent** (BT wire protocol) and **WebRTC** (RTC data channels) simultaneously — or either one on its own. BittSeeder handles both protocols from a single binary. Both share the same torrent file, piece data, tracker URL list, and upload counter. You choose the protocol globally or per-torrent via YAML or CLI. ---- +**✨ Recent Enhancements**: Private torrent support (BEP-27), cross-platform path normalization, persistent detail views, enhanced dark mode, and improved UI/UX. See [Recent Improvements](#recent-improvements) for details. ## Table of contents @@ -38,7 +39,27 @@ BittSeeder handles both protocols from a single binary. Both share the same torr - [Architecture overview](#architecture-overview) - [License](#license) ---- +## Recent Improvements + +### v0.1.1 Series Enhancements + +#### UI/UX Improvements +- **Persistent Detail Views**: Torrent detail dropdowns now stay open during real-time stats updates instead of closing every second +- **Cross-Platform Path Normalization**: All file paths display with forward slashes (`/`) on all operating systems for consistency +- **Enhanced Dark Mode**: Comprehensive theme support across detail rows, code blocks, expand buttons, and interactive elements +- **Reorganized Add Torrent Form**: Improved layout with logical field grouping, optimal sizing, and better visual hierarchy +- **Professional Button Styling**: Modern expand/collapse buttons with smooth animations and proper hover effects + +#### Backend Features +- **Private Torrent Support (BEP-27)**: Create private torrents with a single toggle; the private flag disables DHT and PEX for private trackers +- **Path Normalization Helper**: Automatic conversion of Windows backslashes to forward slashes in all API responses +- **Smart Table Updates**: Stats now update via targeted DOM manipulation instead of full table rebuilds for better performance + +#### Code Quality +- **Zero Clippy Warnings**: All code quality issues resolved +- **Comprehensive Testing**: All test fixtures updated for new features +- **Type Safety**: Proper handling of `Cow` and `String` types throughout the codebase + ## Requirements @@ -243,10 +264,18 @@ torrents: rtc_interval: 3 # seconds (converted to ms internally) enabled: true + - name: "Movie Directory" # Directories are automatically scanned + file: + - /data/movies/ # All files in this directory will be included + allowed_extensions: ["mp4", "mkv", "avi"] + trackers: + - http://tracker.example.com/announce + enabled: true + - name: "Re-seed from .torrent" torrent_file: /data/existing.torrent file: - - /data/existing_content/ + - /data/existing_content/ # Directory scanned automatically trackers: [] # read from .torrent file automatically enabled: true ``` @@ -281,7 +310,8 @@ torrents: | Key | Type | Default | Description | |---|---|---|---| | `name` | `string` | file name | Torrent display name | -| `file` | `[path]` | — | Files or directories to seed (required unless `torrent_file` is set) | +| `file` | `[path]` | — | Files or directories to seed (required unless `torrent_file` is set; directories are automatically scanned) | +| `allowed_extensions` | `[string]` | — | Only include files with these extensions when scanning directories (e.g. `["mp4", "mkv", "avi"]`) | | `trackers` | `[url]` | `[]` | Tracker announce URLs | | `torrent_file` | `path` | — | Existing `.torrent` to re-seed | | `magnet` | `string` | — | Magnet URI (tracker URLs extracted automatically) | @@ -293,8 +323,61 @@ torrents: | `ice` | `[url]` | *(global)* | Per-torrent ICE server list | | `rtc_interval` | `u64` | *(global)* | Per-torrent RTC signaling interval in **seconds** | | `enabled` | `bool` | `true` | Set `false` to skip this torrent without removing it | +| `private` | `bool` | `false` | **Private torrent flag** (BEP-27) — disables DHT and PEX for private trackers | + +### Directory Scanning & File Validation + +**Automatic directory scanning:** When a directory is specified in the `file` field, BittSeeder automatically scans it recursively and includes all found files in the torrent. + +**Example - Seed a directory:** + +```yaml +torrents: + - name: "Movies" + file: + - /movies/ + allowed_extensions: ["mp4", "mkv", "avi"] + enabled: true +``` + +**Example - Mix individual files and directories:** + +```yaml +torrents: + - name: "Media Collection" + file: + - /movies/blockbuster.mp4 + - /tv-shows/ # Directory - automatically scanned + - /music/playlist.m3u + allowed_extensions: ["mp4", "mkv", "avi", "mp3", "flac"] + enabled: true +``` + +**Extension filtering:** Use `allowed_extensions` to only include specific file types when scanning directories: + +```yaml +torrents: + - name: "Music Library" + file: + - /music/ + allowed_extensions: + - "mp3" + - "flac" + - "wav" + enabled: true +``` -> **Protocol resolution order:** per-torrent `protocol` → CLI `--protocol` → YAML `config.protocol` → `both` +**Automatic file validation:** Files are automatically validated at runtime when seeding starts. BittSeeder checks: +- ✅ File exists and is readable +- ✅ File is not empty +- ✅ File can be opened and read +- ✅ File data matches torrent piece hashes + +If a file or directory listed in `file` does not exist at startup, the entire torrent entry is **skipped gracefully** with a warning logged — BittSeeder continues running and serves all other torrents normally. Fix the path or restore the file, then hot-reload the config to activate the entry. + +If validation fails at serve time (e.g. corrupt data), the torrent will not seed and an error is logged indicating which file and/or piece failed validation. + +**Source Folder fallback in web UI:** When adding torrents via the web UI, if no Data Path is specified, the system automatically uses the configured Source Folder from Global Settings → Network (the same folder used by Batch Add). This makes it easy to add multiple torrents from the same location. > **ICE resolution order:** per-torrent `ice` → YAML `config.rtc_ice_servers` → Google STUN x2 --- @@ -306,13 +389,19 @@ The web UI starts automatically on port `8090` (override with `--web-port` or `c Features: - Live **Peers** and **Upload Speed** charts (24 h / 48 h / 72 h window) - Per-torrent uploaded bytes and active peer count, updated every second via WebSocket +- **Persistent Detail Views** — expandable torrent details that stay open during real-time stats updates (no more flickering!) +- **Cross-Platform Path Display** — all file paths normalized to forward slashes for consistent display on Windows, Linux, and macOS - Add, edit, enable/disable, and delete torrents without restarting +- **Improved Form Layout** — reorganized Add Torrent form with logical field grouping and optimal sizing +- **Private Torrent Support** — create private torrents (BEP-27) with a single toggle; private flag disables DHT and PEX +- **Enhanced Dark Mode** — full theme support across all UI elements including detail rows, code blocks, and interactive elements - **Batch Add** — scan a configured source folder and register every top-level file/folder as a new torrent entry in one click - **Upload `.torrent`** — upload an existing `.torrent` file directly from your browser instead of typing a server-side path - **Upload Files / Folders** — upload any file or entire folder from your browser directly to the server. Files are transferred in chunks with per-chunk SHA-256 validation; a full-file SHA-256 hash check is performed on finalize. Upload progress and hash-verification progress are shown live -- Dark/light theme toggle +- Dark/light theme toggle with comprehensive theming - Live **Console** log viewer (last 10 000 lines, streaming via WebSocket) - Fully responsive — works on desktop, tablet, and mobile +- Fully responsive — works on desktop, tablet, and mobile ### Authentication @@ -552,7 +641,8 @@ BittSeeder implements the following [BitTorrent Enhancement Proposals](https://w | [BEP 15](https://www.bittorrent.org/beps/bep_0015.html) | UDP Tracker Protocol | Full connect/announce/stopped lifecycle over UDP (`udp://` tracker URLs) | | [BEP 19](https://www.bittorrent.org/beps/bep_0019.html) | WebSeed (GetRight style) | `url-list` field written to generated `.torrent` files when `--webseed` / `webseed` entries are configured | | [BEP 23](https://www.bittorrent.org/beps/bep_0023.html) | Tracker Returns Compact Peer Lists | Always requests `compact=1`; parses 6-byte compact IPv4 peer entries (4-byte IP + 2-byte port) | -| [BEP 52](https://www.bittorrent.org/beps/bep_0052.html) | The BitTorrent Protocol v2 | Full v2 torrent creation (SHA-256 piece hashing, per-file Merkle trees, `file tree` info structure); hybrid v1+v2 torrents; v2 magnet links (`xt=urn:btmh:1220…`) | +| [BEP 27](https://www.bittorrent.org/beps/bep_0027.html) | Private Torrents | Private flag (`private:1`) in torrent info dict — disables DHT and PEX | +| [BEP 52](https://www.bittorrent.org/beps/bep_0052.html) | The BitTorrent Protocol v2 | Full v2 torrent creation (SHA-256 piece hashing, per-file Merkle trees, `file tree` info structure); hybrid v1+v2 torrents; v2 magnet links (`xt=urn:btmh:1220…`). For hybrid torrents both the v1 SHA-1 hash and the truncated v2 hash (first 20 bytes of SHA-256, per BEP 52 §Handshake) are registered — peers connecting via either the v1 or the v2 magnet URI are accepted simultaneously. | --- @@ -564,10 +654,10 @@ BittSeeder uses the Azureus-style peer ID format: -BS0100-<12 random digits> ``` -| Part | Value | Meaning | -|---|---|---| +| Part | Value | Meaning | +|---|---|--------------------| | `BS` | client code | **B**itt**S**eeder | -| `0100` | version digits | v0.1.0 | +| `0100` | version digits | v0.1.1 | | 12 digits | random | unique per session | BitTorrent clients that maintain a known-client database (e.g. qBittorrent, Transmission) will display the raw code (`TS`) until BittSeeder is added to their fingerprint database. BittSeeder will **not** be misidentified as any other client. diff --git a/bittseeder.desktop b/bittseeder.desktop index a964989..baea02f 100644 --- a/bittseeder.desktop +++ b/bittseeder.desktop @@ -1,5 +1,5 @@ [Desktop Entry] -Version=0.1.0 +Version=0.1.1 Type=Application Name=BittSeeder Comment=Unified BT+RTC Seeder — Seeds over BitTorrent and/or WebRTC simultaneously diff --git a/docker/Dockerfile b/docker/Dockerfile index b4d1291..9f21d3b 100644 --- a/docker/Dockerfile +++ b/docker/Dockerfile @@ -3,7 +3,7 @@ FROM rust:alpine RUN apk update --no-interactive RUN apk add git musl-dev curl pkgconfig openssl-dev openssl-libs-static --no-interactive RUN git clone https://github.com/Power2All/bittseeder.git /app/bittseeder -RUN cd /app/bittseeder && git checkout tags/v0.1.0 +RUN cd /app/bittseeder && git checkout tags/v0.1.1 WORKDIR /app/bittseeder RUN cd /app/bittseeder RUN cargo build --release && rm -Rf target/release/.fingerprint target/release/build target/release/deps target/release/examples target/release/incremental diff --git a/docker/build.bat b/docker/build.bat index 67cb168..bf867bc 100644 --- a/docker/build.bat +++ b/docker/build.bat @@ -1,5 +1,5 @@ @echo off -docker build --no-cache -t power2all/bittseeder:v0.1.0 -t power2all/bittseeder:latest . -docker push power2all/bittseeder:v0.1.0 +docker build --no-cache -t power2all/bittseeder:v0.1.1 -t power2all/bittseeder:latest . +docker push power2all/bittseeder:v0.1.1 docker push power2all/bittseeder:latest \ No newline at end of file diff --git a/src/config/files.rs b/src/config/files.rs new file mode 100644 index 0000000..6b4da0f --- /dev/null +++ b/src/config/files.rs @@ -0,0 +1,106 @@ +use std::path::PathBuf; +use std::fs::File; +use std::io::Read; + +pub fn validate_file(path: &PathBuf) -> Result<(), String> { + let metadata = match std::fs::metadata(path) { + Ok(meta) => meta, + Err(e) => return Err(format!("cannot read file metadata: {}", e)), + }; + if !metadata.is_file() { + return Err("not a regular file".to_string()); + } + if metadata.len() == 0 { + return Err("file is empty".to_string()); + } + let file = match File::open(path) { + Ok(f) => f, + Err(e) => return Err(format!("cannot open file: {}", e)), + }; + let mut buffer = [0u8; 4096]; + let mut handle = file; + match handle.read(&mut buffer) { + Ok(n) => { + if n == 0 { + return Err("cannot read from file".to_string()); + } + } + Err(e) => return Err(format!("file read error: {}", e)), + } + Ok(()) +} + +pub fn scan_folder_for_files( + folder: &str, + validate: bool, + allowed_extensions: Option<&[String]>, +) -> Result, String> { + let folder_path = PathBuf::from(folder); + if !folder_path.exists() || !folder_path.is_dir() { + return Err(format!("Scan folder does not exist or is not a directory: {}", folder)); + } + let mut found_files = Vec::new(); + let mut invalid_count = 0; + let mut skipped_count = 0; + let entries = match std::fs::read_dir(&folder_path) { + Ok(entries) => entries, + Err(e) => return Err(format!("Failed to read directory {}: {}", folder, e)), + }; + for entry in entries.filter_map(|e| e.ok()) { + let path = entry.path(); + if path.file_name() + .and_then(|n| n.to_str()) + .map(|s| s.starts_with('.')) + .unwrap_or(false) + { + continue; + } + if let Some(allowed) = allowed_extensions + && let Some(ext) = path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_lowercase(); + if !allowed.iter().any(|a| a.to_lowercase() == ext_lower) { + skipped_count += 1; + continue; + } + } + if path.is_file() { + if validate { + match validate_file(&path) { + Ok(_) => found_files.push(path), + Err(e) => { + invalid_count += 1; + log::warn!("[Config] Skipping invalid file {}: {}", path.display(), e); + } + } + } else { + found_files.push(path); + } + } else if path.is_dir() { + match scan_folder_for_files(path.to_str().unwrap(), validate, allowed_extensions) { + Ok(sub_files) => found_files.extend(sub_files), + Err(e) => { + log::warn!("[Config] Failed to scan subdirectory {}: {}", path.display(), e); + } + } + } + } + if found_files.is_empty() { + let mut error_msg = format!("No valid files found in folder: {}", folder); + if invalid_count > 0 { + error_msg.push_str(&format!(" ({} invalid files skipped)", invalid_count)); + } + if skipped_count > 0 { + error_msg.push_str(&format!(" ({} files skipped by filters)", skipped_count)); + } + return Err(error_msg); + } + log::info!( + "[Config] Scanned folder '{}': found {} valid files, {} invalid, {} skipped", + folder, + found_files.len(), + invalid_count, + skipped_count + ); + Ok(found_files) +} \ No newline at end of file diff --git a/src/config/impls/seeder_config.rs b/src/config/impls/seeder_config.rs index 825d11c..14e6039 100644 --- a/src/config/impls/seeder_config.rs +++ b/src/config/impls/seeder_config.rs @@ -24,6 +24,7 @@ impl Default for SeederConfig { upload_limit: None, proxy: None, show_stats: true, + private: false, } } } \ No newline at end of file diff --git a/src/config/impls/torrents_config.rs b/src/config/impls/torrents_config.rs index f3faeb2..98f58ba 100644 --- a/src/config/impls/torrents_config.rs +++ b/src/config/impls/torrents_config.rs @@ -2,6 +2,7 @@ use crate::config::enums::seed_protocol::SeedProtocol; use crate::config::structs::proxy_config::ProxyConfig; use crate::config::structs::seeder_config::SeederConfig; use crate::config::structs::torrent_entry::TorrentEntry; +use crate::torrent::torrent::collect_dir_files; use crate::torrent::enums::torrent_version::TorrentVersion; use std::path::PathBuf; @@ -14,11 +15,103 @@ impl TorrentEntry { global_ice: &[String], global_rtc_interval_ms: u64, ) -> Result { + if let Some(ref torrent_path) = self.torrent_file + && !self.create_torrent + { + let path = PathBuf::from(torrent_path); + if !path.exists() { + return Err(format!("Torrent file does not exist: {}. Either create the torrent first or enable 'Create torrent if it doesn't exist'.", path.display())); + } + } if self.file.is_empty() && self.torrent_file.is_none() { return Err("torrent entry needs at least one file or a torrent_file path".to_string()); } - let file_paths: Vec = self.file.iter().map(PathBuf::from).collect(); - let out_file = self.out.as_ref().map(PathBuf::from); + let file_paths = if self.allowed_extensions.is_some() { + let mut filtered_files = Vec::new(); + for file_path in &self.file { + let path = PathBuf::from(file_path); + if path.is_dir() { + if !path.exists() { + return Err(format!("Directory not found: {}", path.display())); + } + let mut dir_files: Vec<(PathBuf, Vec)> = Vec::new(); + match collect_dir_files(&path, &path, &mut dir_files) { + Ok(_) => { + for (file_path, _name_parts) in dir_files { + if let Some(allowed) = &self.allowed_extensions + && let Some(ext) = file_path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_lowercase(); + if !allowed.iter().any(|a| a.to_lowercase() == ext_lower) { + log::debug!("[Config] Skipping {} (extension not allowed)", file_path.display()); + continue; + } + } + filtered_files.push(file_path); + } + } + Err(e) => { + log::warn!("[Config] Failed to scan directory {}: {} - skipping", path.display(), e); + } + } + } else { + if !path.exists() { + return Err(format!("File not found: {}", path.display())); + } + if let Some(allowed) = &self.allowed_extensions + && let Some(ext) = path.extension().and_then(|e| e.to_str()) + { + let ext_lower = ext.to_lowercase(); + if !allowed.iter().any(|a| a.to_lowercase() == ext_lower) { + log::debug!("[Config] Skipping {} (extension not allowed)", path.display()); + continue; + } + } + filtered_files.push(path); + } + } + if filtered_files.is_empty() && self.torrent_file.is_none() && !self.file.is_empty() { + return Err("No files found after extension filtering. Check your allowed_extensions setting.".to_string()); + } + log::info!( + "[Config] Processed {} file(s): found {} after extension filtering", + self.file.len(), + filtered_files.len() + ); + filtered_files + } else { + let mut all_files = Vec::new(); + for file_path in &self.file { + let path = PathBuf::from(file_path); + if path.is_dir() { + if !path.exists() { + return Err(format!("Directory not found: {}", path.display())); + } + let mut dir_files: Vec<(PathBuf, Vec)> = Vec::new(); + match collect_dir_files(&path, &path, &mut dir_files) { + Ok(_) => { + for (file_path, _name_parts) in dir_files { + all_files.push(file_path); + } + } + Err(e) => { + log::warn!("[Config] Failed to scan directory {}: {} - skipping", path.display(), e); + } + } + } else { + if !path.exists() { + return Err(format!("File not found: {}", path.display())); + } + all_files.push(path); + } + } + all_files + }; + let out_file = if self.create_torrent { + self.torrent_file.as_ref().map(PathBuf::from) + } else { + None + }; let webseed_urls = self.webseed.clone().unwrap_or_default(); let protocol = self.protocol.clone().unwrap_or(global_protocol); let ice_servers = self.ice.clone().unwrap_or_else(|| { @@ -56,6 +149,7 @@ impl TorrentEntry { upload_limit: self.upload_limit, proxy: proxy.cloned(), show_stats: true, + private: self.private, }) } } \ No newline at end of file diff --git a/src/config/mod.rs b/src/config/mod.rs index 91b7e9a..29ca9e8 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,3 +1,4 @@ +pub mod files; pub mod enums; pub mod impls; pub mod structs; \ No newline at end of file diff --git a/src/config/structs/global_config.rs b/src/config/structs/global_config.rs index f4d613f..ebdb077 100644 --- a/src/config/structs/global_config.rs +++ b/src/config/structs/global_config.rs @@ -28,4 +28,7 @@ pub struct GlobalConfig { pub letsencrypt_http_port: Option, pub web_login_rate_limit: Option, pub totp_secret: Option, + pub validate_files: Option, + pub skip_invalid_files: Option, + pub allowed_extensions: Option>, } \ No newline at end of file diff --git a/src/config/structs/seeder_config.rs b/src/config/structs/seeder_config.rs index 0057870..d11bb61 100644 --- a/src/config/structs/seeder_config.rs +++ b/src/config/structs/seeder_config.rs @@ -21,4 +21,5 @@ pub struct SeederConfig { pub upload_limit: Option, pub proxy: Option, pub show_stats: bool, + pub private: bool, } \ No newline at end of file diff --git a/src/config/structs/torrent_entry.rs b/src/config/structs/torrent_entry.rs index 1b67fd4..2c2f978 100644 --- a/src/config/structs/torrent_entry.rs +++ b/src/config/structs/torrent_entry.rs @@ -10,7 +10,6 @@ fn default_true() -> bool { #[derive(Debug, Clone, Deserialize, Serialize)] pub struct TorrentEntry { - pub out: Option, pub name: Option, #[serde(default)] pub file: Vec, @@ -23,8 +22,13 @@ pub struct TorrentEntry { pub protocol: Option, pub version: Option, pub torrent_file: Option, + pub create_torrent: bool, pub magnet: Option, #[serde(default = "default_true")] pub enabled: bool, pub upload_limit: Option, + #[serde(default)] + pub allowed_extensions: Option>, + #[serde(default)] + pub private: bool, } \ No newline at end of file diff --git a/src/main.rs b/src/main.rs index 2ef2986..8017e52 100644 --- a/src/main.rs +++ b/src/main.rs @@ -332,6 +332,7 @@ async fn main() { upload_limit: None, proxy, show_stats: true, + private: false, }; println!("=== Seeder (BT+RTC) ==="); println!("Protocol: {}", match &protocol { @@ -501,15 +502,15 @@ async fn seed_one( SeedProtocol::Rtc => "rtc", SeedProtocol::Both => "both", }; - let version_str = match config.version { - TorrentVersion::V1 => "v1", - TorrentVersion::V2 => "v2", - TorrentVersion::Hybrid => "hybrid", - }; - print!("[{}] Hashing pieces ({}, protocol={})… ", label, version_str, proto_str); + print!("[{}] Hashing pieces (protocol={})… ", label, proto_str); let torrent_info = match TorrentBuilder::build(&config) { Ok(ti) => { - println!("done."); + let version_str = match ti.version { + TorrentVersion::V1 => "v1", + TorrentVersion::V2 => "v2", + TorrentVersion::Hybrid => "hybrid", + }; + println!("done ({}).", version_str); ti } Err(e) => { diff --git a/src/seeder/impls/seeder.rs b/src/seeder/impls/seeder.rs index 0f82724..17be9f4 100644 --- a/src/seeder/impls/seeder.rs +++ b/src/seeder/impls/seeder.rs @@ -59,7 +59,12 @@ impl Seeder { if let Some(v2h) = self.torrent_info.v2_info_hash { println!("v2Hash: {}", hex::encode(v2h)); } - println!("\nMagnet URI:\n{}\n", self.torrent_info.magnet_uri); + if let Some(ref v2_magnet) = self.torrent_info.v2_magnet_uri { + println!("\nMagnet URI (v1):\n{}\n", self.torrent_info.magnet_uri); + println!("Magnet URI (v2):\n{}\n", v2_magnet); + } else { + println!("\nMagnet URI:\n{}\n", self.torrent_info.magnet_uri); + } println!("Share the magnet URI or the .torrent file with leechers.\n"); println!("Data :"); for file in &self.torrent_info.files { @@ -76,18 +81,13 @@ impl Seeder { if missing { return Err("one or more data files are missing — cannot seed".into()); } - let rate_limiter: Option = self.config.upload_limit.and_then(|kbs| { NonZeroU32::new(kbs as u32 * 1024).map(|quota_cells| { Arc::new(RateLimiter::direct(Quota::per_second(quota_cells))) }) }); - - // Create the internal stop channel early so every subtask (stats, peers, listener) - // can receive the same shutdown signal. let (stop_tx, stop_rx) = tokio::sync::watch::channel(false); - let protocol = &self.config.protocol; if self.config.show_stats { let uploaded_stats = Arc::clone(&self.uploaded); @@ -121,16 +121,12 @@ impl Seeder { } }); } - - // Announce to ALL valid BT trackers (http, https, udp). let mut bt_announce_interval: u64 = 300; let bt_trackers: Vec = if protocol.has_bt() { self.try_bt_announce_all(&mut bt_announce_interval).await } else { Vec::new() }; - - // Collect all HTTP(S) trackers for RTC. let mut rtc_trackers: Vec = Vec::new(); let mut initial_peer_conn: Option = None; if protocol.has_rtc() { @@ -154,10 +150,7 @@ impl Seeder { } } } - println!("Seeding… (Ctrl+C to stop)\n"); - - // BT re-announce task — iterates over ALL trackers every interval. let bt_reannounce_handle = if !bt_trackers.is_empty() { let trackers_ann = bt_trackers.clone(); let uploaded_ann = Arc::clone(&self.uploaded); @@ -171,9 +164,10 @@ impl Seeder { for tracker in &trackers_ann { match tracker.announce(up, "").await { Ok(resp) => { - log::info!("[Tracker/BT] Re-announced (interval={}s)", resp.interval); + println!("[Tracker/BT] Re-announced — interval={}s, peers={}", resp.interval, resp.peers.len()); } Err(e) => { + println!("[Tracker/BT] Re-announce failed: {}", e); log::warn!("[Tracker/BT] Re-announce failed: {}", e); } } @@ -188,7 +182,6 @@ impl Seeder { } else { None }; - if protocol.has_bt() { if let Some(ref reg) = registry { let entry = TorrentRegistryEntry { @@ -200,7 +193,14 @@ impl Seeder { stop_rx: stop_rx.clone(), }; let mut map = reg.write().await; - map.insert(self.torrent_info.info_hash, entry); + map.insert(self.torrent_info.info_hash, entry.clone()); + // Also register the truncated v2 hash so peers connecting with + // the v2 info hash (first 20 bytes of SHA-256) are accepted. + if let Some(v2) = self.torrent_info.v2_info_hash { + let mut truncated = [0u8; 20]; + truncated.copy_from_slice(&v2[..20]); + map.insert(truncated, entry); + } } else { let listen_addr = format!("0.0.0.0:{}", self.config.listen_port); let listener = tokio::net::TcpListener::bind(&listen_addr).await?; @@ -269,8 +269,6 @@ impl Seeder { }); } } - - // RTC signaling task — announces to ALL HTTP trackers, collects answers from all. let rtc_handle = if protocol.has_rtc() && !rtc_trackers.is_empty() && let Some(current_pc) = initial_peer_conn.take() @@ -288,7 +286,6 @@ impl Seeder { let mut rtc_interval_ms = config_rtc.rtc_interval_ms; loop { let uploaded = uploaded_rtc.load(Ordering::Relaxed); - // Announce to every RTC tracker and collect all answers. let mut all_answers = Vec::new(); for tracker in &trackers_rtc { match tracker.announce_seeder(¤t_pc.sdp_offer, uploaded, event).await { @@ -334,7 +331,6 @@ impl Seeder { _ = tokio::time::sleep(std::time::Duration::from_millis(rtc_interval_ms)) => {} _ = srx.changed() => { if *srx.borrow() { - // Send "stopped" to all RTC trackers on shutdown. let up = uploaded_rtc.load(Ordering::Relaxed); for tracker in &trackers_rtc { let _ = tokio::time::timeout( @@ -351,8 +347,6 @@ impl Seeder { } else { None }; - - // Wait for either Ctrl+C or an external stop signal (e.g. torrent disabled/deleted via UI). tokio::select! { _ = tokio::signal::ctrl_c() => { println!("\n[Seeder] Ctrl+C received — shutting down…"); @@ -375,8 +369,12 @@ impl Seeder { && let Some(ref reg) = registry { let mut map = reg.write().await; map.remove(&self.torrent_info.info_hash); + if let Some(v2) = self.torrent_info.v2_info_hash { + let mut truncated = [0u8; 20]; + truncated.copy_from_slice(&v2[..20]); + map.remove(&truncated); + } } - // Send "stopped" to ALL BT trackers. if !bt_trackers.is_empty() { let uploaded = self.uploaded.load(Ordering::Relaxed); log::info!("[Tracker/BT] Sending 'stopped' announcement to {} tracker(s)…", bt_trackers.len()); @@ -395,36 +393,42 @@ impl Seeder { Ok(()) } - /// Announce to ALL valid BT tracker URLs (http, https, udp). - /// Returns a client for every tracker that accepted the "started" announce. - /// Sets `interval_out` to the minimum interval reported by any tracker. async fn try_bt_announce_all(&self, interval_out: &mut u64) -> Vec { let urls = &self.torrent_info.tracker_urls; if urls.is_empty() { log::info!("[Tracker/BT] No tracker configured — seeding without announcing."); return Vec::new(); } + let mut hashes: Vec<([u8; 20], &str)> = vec![(self.torrent_info.info_hash, "v1")]; + if let Some(v2) = self.torrent_info.v2_info_hash { + let mut truncated = [0u8; 20]; + truncated.copy_from_slice(&v2[..20]); + hashes.push((truncated, "v2")); + } let mut trackers = Vec::new(); for url in urls { if url.starts_with("udp://") || url.starts_with("http://") || url.starts_with("https://") { - let tracker = BtTrackerClient::new( - url.clone(), - self.torrent_info.info_hash, - self.peer_id, - self.config.listen_port, - self.config.proxy.as_ref(), - ); - match tracker.announce(0, "started").await { - Ok(resp) => { - let interval = resp.interval.max(30); - if interval < *interval_out { - *interval_out = interval; + for &(info_hash, label) in &hashes { + let tracker = BtTrackerClient::new( + url.clone(), + info_hash, + self.peer_id, + self.config.listen_port, + self.config.proxy.as_ref(), + ); + match tracker.announce(0, "started").await { + Ok(resp) => { + let interval = resp.interval.max(30); + if interval < *interval_out { + *interval_out = interval; + } + println!("[Tracker/BT] Announced ({}) to {} — interval={}s, peers={}", label, url, interval, resp.peers.len()); + trackers.push(tracker); + } + Err(e) => { + println!("[Tracker/BT] Announce ({}) to {} failed: {}", label, url, e); + log::warn!("[Tracker/BT] {} ({}) failed: {} — skipping", url, label, e); } - log::info!("[Tracker/BT] Announced to {}: interval={}s", url, interval); - trackers.push(tracker); - } - Err(e) => { - log::warn!("[Tracker/BT] {} failed: {} — skipping", url, e); } } } @@ -435,7 +439,6 @@ impl Seeder { trackers } - /// Returns clients for ALL HTTP(S) tracker URLs (RTC only uses HTTP). fn pick_rtc_trackers(&self) -> Vec { let urls = &self.torrent_info.tracker_urls; if urls.is_empty() { @@ -459,4 +462,4 @@ impl Seeder { } trackers } -} +} \ No newline at end of file diff --git a/src/seeder/seeder.rs b/src/seeder/seeder.rs index 6c06a50..9ad8d9b 100644 --- a/src/seeder/seeder.rs +++ b/src/seeder/seeder.rs @@ -136,6 +136,10 @@ async fn send_unchoke(stream: &mut TcpStream) -> std::io::Result<()> { stream.write_all(&msg).await } +async fn send_keepalive(stream: &mut TcpStream) -> std::io::Result<()> { + stream.write_all(&[0u8; 4]).await +} + async fn send_piece_block( stream: &mut TcpStream, index: u32, @@ -193,14 +197,29 @@ async fn handle_peer_connection( addr, peer_id_hex.get(..8).unwrap_or(&peer_id_hex) ); + // Log extension bits advertised by the peer (bytes 20-27 of handshake). + let ext_bytes = &peer_hs[20..28]; + log::debug!("[BT] {} ext flags: {:02x?}", addr, ext_bytes); + if let Err(e) = send_bitfield(&mut stream, torrent_info.piece_count).await { log::debug!("[BT] Bitfield send failed to {}: {}", addr, e); return; } + log::debug!("[BT] → bitfield ({} pieces) to {}", torrent_info.piece_count, addr); + if let Err(e) = send_unchoke(&mut stream).await { log::debug!("[BT] Unchoke send failed to {}: {}", addr, e); return; } + log::debug!("[BT] → unchoke to {}", addr); + + // Send keepalive every 2 minutes to prevent the peer timing out during idle periods. + let keepalive_period = std::time::Duration::from_secs(120); + let mut keepalive = tokio::time::interval_at( + tokio::time::Instant::now() + keepalive_period, + keepalive_period, + ); + loop { tokio::select! { biased; @@ -211,22 +230,30 @@ async fn handle_peer_connection( break; } } + _ = keepalive.tick() => { + log::debug!("[BT] → keepalive to {}", addr); + if let Err(e) = send_keepalive(&mut stream).await { + log::debug!("[BT] Keepalive send failed to {}: {}", addr, e); + break; + } + } msg = read_message(&mut stream) => { match msg { - Ok(None) => {} + Ok(None) => { + log::debug!("[BT] ← keepalive from {}", addr); + } Ok(Some((id, payload))) => { match id { MSG_INTERESTED => { - log::debug!( - "[BT] Peer {}… interested", - peer_id_hex.get(..8).unwrap_or(&peer_id_hex) - ); + log::debug!("[BT] ← interested from {}", addr); + if let Err(e) = send_unchoke(&mut stream).await { + log::debug!("[BT] Unchoke send failed to {}: {}", addr, e); + break; + } + log::debug!("[BT] → unchoke to {}", addr); } MSG_NOT_INTERESTED => { - log::debug!( - "[BT] Peer {}… not interested", - peer_id_hex.get(..8).unwrap_or(&peer_id_hex) - ); + log::debug!("[BT] ← not-interested from {}", addr); } MSG_REQUEST => { if payload.len() < 12 { @@ -237,8 +264,16 @@ async fn handle_peer_connection( let begin = u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]); let length = u32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]); let length = length.min(MAX_BLOCK_SIZE); - log::debug!("[BT] Request: piece={} begin={} len={}", index, begin, length); - match read_block(&torrent_info, index as usize, begin as u64, length as usize) { + log::debug!("[BT] ← request piece={} begin={} len={} from {}", index, begin, length, addr); + let ti = Arc::clone(&torrent_info); + let block_result = tokio::task::spawn_blocking(move || { + read_block(&ti, index as usize, begin as u64, length as usize) + }).await; + let block_result = match block_result { + Ok(r) => r, + Err(e) => { log::warn!("[BT] spawn_blocking panicked: {}", e); break; } + }; + match block_result { Ok(data) => { if let Some(rl) = &rate_limiter { let n = NonZeroU32::new(data.len() as u32) @@ -246,6 +281,7 @@ async fn handle_peer_connection( rl.until_n_ready(n).await.ok(); } let bytes_sent = data.len() as u64; + log::debug!("[BT] → piece piece={} begin={} len={} to {}", index, begin, bytes_sent, addr); if let Err(e) = send_piece_block(&mut stream, index, begin, &data).await { log::debug!("[BT] Send error to {}: {}", addr, e); break; @@ -254,14 +290,29 @@ async fn handle_peer_connection( peer_uploaded.fetch_add(bytes_sent, Ordering::Relaxed); } Err(e) => { - log::warn!("[BT] Block read error: {}", e); + log::warn!("[BT] Block read error piece={} begin={}: {}", index, begin, e); break; } } } - MSG_CANCEL | MSG_CHOKE | MSG_UNCHOKE | MSG_HAVE => {} + MSG_CANCEL => { + if payload.len() >= 12 { + let index = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]); + let begin = u32::from_be_bytes([payload[4], payload[5], payload[6], payload[7]]); + let length = u32::from_be_bytes([payload[8], payload[9], payload[10], payload[11]]); + log::debug!("[BT] ← cancel piece={} begin={} len={} from {}", index, begin, length, addr); + } + } + MSG_CHOKE => log::debug!("[BT] ← choke from {}", addr), + MSG_UNCHOKE => log::debug!("[BT] ← unchoke from {}", addr), + MSG_HAVE => { + if payload.len() >= 4 { + let piece = u32::from_be_bytes([payload[0], payload[1], payload[2], payload[3]]); + log::debug!("[BT] ← have piece={} from {}", piece, addr); + } + } _ => { - log::debug!("[BT] Unknown message id={} from {}", id, addr); + log::debug!("[BT] ← unknown msg id={} len={} from {}", id, payload.len(), addr); } } } diff --git a/src/torrent/impls/torrent_builder.rs b/src/torrent/impls/torrent_builder.rs index cbbad14..5bca077 100644 --- a/src/torrent/impls/torrent_builder.rs +++ b/src/torrent/impls/torrent_builder.rs @@ -8,10 +8,11 @@ use crate::torrent::torrent::{ build_magnet_uri_simple, build_v1, build_v2, + build_v2_magnet_uri, collect_dir_files, parse_magnet, parse_torrent_meta, - torrent_creation_date + torrent_creation_date, }; use std::io; @@ -63,7 +64,25 @@ impl TorrentBuilder { .collect() }; let name = config.name.clone().unwrap_or(meta.name); - let magnet_uri = build_magnet_uri_simple(&hex::encode(meta.info_hash), &name, &tracker_urls); + let version = match (meta.pieces.is_empty(), meta.v2_info_hash.is_some()) { + (false, true) => TorrentVersion::Hybrid, + (true, true) => TorrentVersion::V2, + _ => TorrentVersion::V1, + }; + let (magnet_uri, v2_magnet_uri) = match &meta.v2_info_hash { + Some(v2h) if version == TorrentVersion::Hybrid => ( + build_magnet_uri_simple(&hex::encode(meta.info_hash), &name, &tracker_urls), + Some(build_v2_magnet_uri(&hex::encode(v2h), &name, &tracker_urls)), + ), + Some(v2h) => ( + build_v2_magnet_uri(&hex::encode(v2h), &name, &tracker_urls), + None, + ), + None => ( + build_magnet_uri_simple(&hex::encode(meta.info_hash), &name, &tracker_urls), + None, + ), + }; return Ok(TorrentInfo { name, piece_length: meta.piece_length, @@ -78,8 +97,9 @@ impl TorrentBuilder { info_hash: meta.info_hash, torrent_bytes: data, magnet_uri, - version: TorrentVersion::V1, - v2_info_hash: None, + v2_magnet_uri, + version, + v2_info_hash: meta.v2_info_hash, tracker_urls, }); } diff --git a/src/torrent/structs/torrent_info.rs b/src/torrent/structs/torrent_info.rs index ed32aa7..354c62d 100644 --- a/src/torrent/structs/torrent_info.rs +++ b/src/torrent/structs/torrent_info.rs @@ -17,6 +17,7 @@ pub struct TorrentInfo { pub info_hash: InfoHash, pub torrent_bytes: Vec, pub magnet_uri: String, + pub v2_magnet_uri: Option, #[allow(dead_code)] pub version: TorrentVersion, pub v2_info_hash: Option, diff --git a/src/torrent/torrent.rs b/src/torrent/torrent.rs index 0a32143..32538ae 100644 --- a/src/torrent/torrent.rs +++ b/src/torrent/torrent.rs @@ -93,6 +93,7 @@ fn read_bint(data: &[u8], pos: usize) -> Result<(i64, usize), String> { pub struct ParsedTorrentMeta { pub info_hash: [u8; 20], + pub v2_info_hash: Option<[u8; 32]>, pub tracker_urls: Vec, pub name: String, pub piece_length: u64, @@ -173,6 +174,7 @@ pub fn parse_torrent_meta(data: &[u8]) -> Result { let mut pieces: Vec = Vec::new(); let mut single_length: Option = None; let mut multi_files: Vec<(u64, Vec)> = Vec::new(); + let mut has_file_tree = false; let mut ipos = 1usize; while ipos < info_bytes.len() && info_bytes[ipos] != b'e' { let (key, after_key) = read_bstring(info_bytes, ipos)?; @@ -198,6 +200,10 @@ pub fn parse_torrent_meta(data: &[u8]) -> Result { ipos = after; single_length = Some(val as u64); } + b"file tree" => { + has_file_tree = true; + ipos = bencode_end(info_bytes, ipos)?; + } b"files" => { if ipos >= info_bytes.len() || info_bytes[ipos] != b'l' { ipos = bencode_end(info_bytes, ipos)?; @@ -272,7 +278,14 @@ pub fn parse_torrent_meta(data: &[u8]) -> Result { len, ) }; - Ok(ParsedTorrentMeta { info_hash, tracker_urls, name, piece_length, pieces, files, total_size }) + let v2_info_hash = if has_file_tree { + let mut h = Sha256::new(); + h.update(info_bytes); + Some(h.finalize().into()) + } else { + None + }; + Ok(ParsedTorrentMeta { info_hash, v2_info_hash, tracker_urls, name, piece_length, pieces, files, total_size }) } pub fn parse_magnet(uri: &str) -> (Vec, Option<[u8; 20]>, Option) { @@ -729,7 +742,7 @@ pub fn build_v2_torrent_bencode( for url in webseed_urls { write_bencode_string(&mut out, url.as_bytes()); } - out.push(b'e'); + out.extend_from_slice(b"e"); } } out.push(b'e'); @@ -789,6 +802,7 @@ pub fn build_v1( info_hash, torrent_bytes, magnet_uri, + v2_magnet_uri: None, version: TorrentVersion::V1, v2_info_hash: None, tracker_urls: tracker_urls.to_vec(), @@ -838,6 +852,7 @@ pub fn build_v2( info_hash, torrent_bytes, magnet_uri, + v2_magnet_uri: None, version: TorrentVersion::V2, v2_info_hash: Some(v2_hash), tracker_urls: tracker_urls.to_vec(), @@ -883,7 +898,8 @@ pub fn build_hybrid( build_v2_torrent_bencode(&info_bytes, tracker_urls, creation_date, webseed_urls, &piece_layers_bytes); let v1_hex = hex::encode(info_hash); let v2_hex = hex::encode(v2_hash); - let magnet_uri = build_hybrid_magnet_uri(&v1_hex, &v2_hex, &name, tracker_urls); + let magnet_uri = build_magnet_uri(&v1_hex, &name, tracker_urls); + let v2_magnet_uri = Some(build_v2_magnet_uri(&v2_hex, &name, tracker_urls)); Ok(TorrentInfo { name, piece_length, @@ -894,6 +910,7 @@ pub fn build_hybrid( info_hash, torrent_bytes, magnet_uri, + v2_magnet_uri, version: TorrentVersion::Hybrid, v2_info_hash: Some(v2_hash), tracker_urls: tracker_urls.to_vec(), diff --git a/src/tracker/impls/http_client.rs b/src/tracker/impls/http_client.rs index 5c2e0cd..c7061b6 100644 --- a/src/tracker/impls/http_client.rs +++ b/src/tracker/impls/http_client.rs @@ -36,6 +36,10 @@ impl BtHttpClient { log::debug!("[Tracker/HTTP] GET {}", url); let resp = self.http.get(&url).send().await?; let body = resp.bytes().await?; - Ok(parse_bt_announce_response(&body)) + let parsed = parse_bt_announce_response(&body); + if parsed.failure_reason.is_none() { + println!("[Tracker/HTTP] OK {} — interval={}s, peers={}", self.tracker_url, parsed.interval, parsed.peers.len()); + } + Ok(parsed) } } \ No newline at end of file diff --git a/src/tracker/impls/udp_client.rs b/src/tracker/impls/udp_client.rs index 289b73a..b0bf371 100644 --- a/src/tracker/impls/udp_client.rs +++ b/src/tracker/impls/udp_client.rs @@ -19,11 +19,48 @@ impl BtUdpClient { ) -> Result> { let addr = parse_udp_tracker_addr(&self.tracker_url) .ok_or_else(|| format!("invalid UDP tracker URL: {}", self.tracker_url))?; - let socket = tokio::net::UdpSocket::bind("0.0.0.0:0").await?; - let remote_addr = tokio::net::lookup_host(&addr) - .await? - .next() - .ok_or("UDP tracker DNS resolution failed")?; + let remote_addrs: Vec<_> = tokio::net::lookup_host(&addr).await?.collect(); + if remote_addrs.is_empty() { + return Err(format!("UDP tracker DNS resolution failed: {}", addr).into()); + } + let mut ipv4_addr = None; + let mut ipv6_addr = None; + for &a in &remote_addrs { + if a.is_ipv4() && ipv4_addr.is_none() { ipv4_addr = Some(a); } + else if a.is_ipv6() && ipv6_addr.is_none() { ipv6_addr = Some(a); } + if ipv4_addr.is_some() && ipv6_addr.is_some() { break; } + } + let candidates: Vec<_> = [ipv4_addr, ipv6_addr].into_iter().flatten().collect(); + let mut merged = AnnounceResponse::default(); + let mut any_ok = false; + let mut last_err: Box = + "no reachable UDP tracker address".into(); + for remote_addr in candidates { + match self.announce_via(remote_addr, uploaded, event).await { + Ok(resp) => { + if !any_ok || resp.interval < merged.interval { + merged.interval = resp.interval; + } + merged.peers.extend(resp.peers); + any_ok = true; + } + Err(e) => { + log::debug!("[Tracker/UDP] {} via {} — skipping", e, remote_addr); + last_err = e; + } + } + } + if any_ok { Ok(merged) } else { Err(last_err) } + } + + async fn announce_via( + &self, + remote_addr: std::net::SocketAddr, + uploaded: u64, + event: &str, + ) -> Result> { + let bind_addr = if remote_addr.is_ipv6() { "[::]:0" } else { "0.0.0.0:0" }; + let socket = tokio::net::UdpSocket::bind(bind_addr).await?; socket.connect(remote_addr).await?; let txid1: u32 = rand::rng().random(); let mut connect_req = [0u8; 16]; @@ -70,7 +107,7 @@ impl BtUdpClient { .await??; let resp = parse_udp_announce_response(&ann_resp[..n], txid2) .ok_or("UDP tracker: invalid announce response")?; - log::debug!("[Tracker/UDP] Announce OK: interval={}s", resp.interval); + println!("[Tracker/UDP] OK via {} — interval={}s, peers={}", remote_addr, resp.interval, resp.peers.len()); Ok(resp) } } \ No newline at end of file diff --git a/src/web/api.rs b/src/web/api.rs index c625f5e..5c207e2 100644 --- a/src/web/api.rs +++ b/src/web/api.rs @@ -34,13 +34,20 @@ use std::collections::{ }; use std::io; use std::sync::Arc; -use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::atomic::{ + AtomicU64, + Ordering +}; use std::time::{ Duration, Instant }; use tokio::sync::broadcast; +fn normalize_path(path: String) -> String { + path.replace(r#"\"#, r#"/"#) +} + const SESSION_TTL: Duration = Duration::from_secs(3600); pub fn verify_totp(secret_base32: &str, code: &str) -> bool { @@ -274,8 +281,8 @@ pub async fn browse(req: HttpRequest, query: Query, data: Data, body: Json) -> HttpResponse { continue; } let path = dir_entry.path(); - let path_str = path.to_string_lossy().into_owned(); + let path_str = normalize_path(path.to_string_lossy().into_owned()); if existing_paths.contains(&path_str) { skipped += 1; continue; @@ -709,7 +734,6 @@ pub async fn batch_add(req: HttpRequest, data: Data) -> HttpResponse { .to_string() }; file.torrents.push(TorrentEntry { - out: None, name: Some(torrent_name), file: vec![path_str], trackers: vec![], @@ -722,6 +746,9 @@ pub async fn batch_add(req: HttpRequest, data: Data) -> HttpResponse { magnet: None, enabled: true, upload_limit: None, + allowed_extensions: None, + create_torrent: false, + private: false, }); added += 1; } @@ -931,7 +958,7 @@ pub async fn file_upload_finalize( hash_progress.fetch_add(n as u64, Ordering::Relaxed); } let computed = hex::encode(hasher.finalize()); - if computed != file_sha256 { + if !file_sha256.is_empty() && computed != file_sha256 { let _ = std::fs::remove_file(&part_path); return Err(io::Error::other(format!( "Integrity check failed: expected SHA-256 {file_sha256}, computed {computed}" diff --git a/src/web/app.js b/src/web/app.js index 3e108be..9484226 100644 --- a/src/web/app.js +++ b/src/web/app.js @@ -35,7 +35,7 @@ function closeMobileMenu() { document.addEventListener('click', function(e) { const menu = document.getElementById('header-actions'); - const btn = document.getElementById('hamburger-btn'); + const btn = document.getElementById('hamburger-btn'); if (menu.classList.contains('open') && !menu.contains(e.target) && !btn.contains(e.target)) { closeMobileMenu(); } @@ -424,6 +424,27 @@ function updateChart() { } } +let expandedRows = new Set(); + +function updateTableStats() { + torrents.forEach((t, i) => { + const name = t.name || (t.file && t.file[0]) || t.torrent_file || t.magnet || 'torrent-' + i; + const st = stats[name] || {}; + const uploaded = st.uploaded !== undefined ? fmtBytes(st.uploaded) : '—'; + const peers = st.peer_count !== undefined ? st.peer_count : '—'; + const peersCell = document.getElementById(`peers-${i}`); + const uploadedCell = document.getElementById(`uploaded-${i}`); + if (peersCell) peersCell.textContent = peers; + if (uploadedCell) uploadedCell.textContent = uploaded; + }); + const totalPeers = Object.values(stats).reduce((sum, s) => sum + (s.peer_count || 0), 0); + const totalRate = Object.values(stats).reduce((sum, s) => sum + (s.uploaded || 0), 0); + if (!ws || ws.readyState !== WebSocket.OPEN) { + document.getElementById('status-text').textContent = + 'Peers: ' + totalPeers + ' Upload: ' + fmtBytes(totalRate) + '/s'; + } +} + function handleStatsMsg(msg) { wsData.push({ ts: msg.ts, peers: msg.peers, rate: msg.rate }); const cutoff = msg.ts - WS_MAX_HOURS * 3600; @@ -431,7 +452,7 @@ function handleStatsMsg(msg) { updateChart(); if (msg.torrents) { stats = msg.torrents; - renderTable(); + updateTableStats(); } document.getElementById('status-text').textContent = 'Peers: ' + msg.peers + ' Upload: ' + fmtBytes(msg.rate) + '/s'; @@ -500,11 +521,7 @@ async function loadStats() { const r = await apiFetch('/api/status'); const data = await r.json(); stats = data.torrents || {}; - if (!ws || ws.readyState !== WebSocket.OPEN) { - document.getElementById('status-text').textContent = - 'Active seeders: ' + Object.keys(stats).length; - } - renderTable(); + updateTableStats(); } catch(_) {} } @@ -528,15 +545,19 @@ function renderTable() { const proto = (t.protocol || 'both').toLowerCase(); const protoClass = proto === 'bt' ? 'proto-bt' : proto === 'rtc' ? 'proto-rtc' : 'proto-both'; const protoLabel = `${escHtml(proto)}`; - html += ` - - - + const isExpanded = expandedRows.has(i); + const iconClass = isExpanded ? 'chevron down' : 'chevron right'; + html += ` + + + ${escHtml(name)} - ${peers} - ${uploaded} + ${peers} + ${uploaded} ${t.enabled ? 'Disable' : 'Enable'} @@ -546,30 +567,54 @@ function renderTable() { - + - + Protocol: ${protoLabel} Version: ${escHtml(version)} Enabled: ${enabledLabel} Upload Limit: ${limit} + ${t.file && t.file.length ? `Files: ${t.file.map(f => '' + escHtml(f) + '').join(', ')}` : ''} + ${t.torrent_file ? `Torrent: ${escHtml(t.torrent_file)}` : ''} + ${t.trackers && t.trackers.length ? `Trackers: ${t.trackers.map(tr => '' + escHtml(tr) + '').join(', ')}` : ''} + ${t.create_torrent ? `Create Torrent: Yes` : ''} + ${t.private ? `Private: Yes` : ''} `; }); document.getElementById('torrent-tbody').innerHTML = html; } - function toggleDetail(i) { - const row = document.getElementById('detail-row-' + i); + const row = document.getElementById('detail-row-' + i); const icon = document.getElementById('expand-icon-' + i); - const open = row.style.display === 'none'; - row.style.display = open ? '' : 'none'; - icon.className = (open ? 'chevron down' : 'chevron right') + ' icon'; + const isOpen = row.style.display !== 'none'; + if (isOpen) { + row.style.display = 'none'; + icon.className = 'chevron right icon'; + expandedRows.delete(i); + } else { + row.style.display = ''; + icon.className = 'chevron down icon'; + expandedRows.add(i); + } icon.style.margin = '0'; } +function toggleCreateTorrent() { + const createTorrent = document.getElementById('f-create-torrent').checked; + const versionSelect = document.getElementById('f-version'); + const privateContainer = document.getElementById('f-private-container'); + if (createTorrent) { + versionSelect.disabled = false; + privateContainer.style.display = 'block'; + } else { + versionSelect.disabled = true; + privateContainer.style.display = 'none'; + } +} + function toggleAddForm() { const f = document.getElementById('add-form'); f.style.display = f.style.display === 'none' ? 'block' : 'none'; @@ -584,10 +629,10 @@ async function addTorrent() { const protocol = document.getElementById('f-protocol').value; const entry = { name: document.getElementById('f-name').value.trim() || null, - out: document.getElementById('f-out').value.trim() || null, file: path ? [path] : [], trackers, torrent_file: document.getElementById('f-torrent-file').value.trim() || null, + create_torrent: document.getElementById('f-create-torrent').checked, magnet: document.getElementById('f-magnet').value.trim() || null, enabled: document.getElementById('f-enabled').checked, upload_limit: parseInt(document.getElementById('f-upload-limit').value) || null, @@ -596,6 +641,11 @@ async function addTorrent() { protocol: protocol !== 'both' ? protocol : null, ice: iceServers, rtc_interval: rtcInterval, + private: document.getElementById('f-private').checked || false, + allowed_extensions: (() => { + const ext = document.getElementById('f-allowed-extensions').value.trim(); + return ext ? ext.split(',').map(s=>s.trim()).filter(Boolean) : null; + })(), }; try { const r = await apiFetch('/api/torrents', { @@ -603,6 +653,16 @@ async function addTorrent() { body: JSON.stringify(entry), }); if (r.ok) { + const data = await r.json(); + if (data.using_source_folder) { + const infoMsg = document.createElement('div'); + infoMsg.className = 'ui info message'; + infoMsg.style.marginTop = '12px'; + infoMsg.innerHTML = ` Using Source Folder from Global Settings: ${data.source_folder}`; + const form = document.getElementById('form-add'); + form.appendChild(infoMsg); + setTimeout(() => infoMsg.remove(), 5000); + } toggleAddForm(); await loadTorrents(); } else { @@ -612,7 +672,6 @@ async function addTorrent() { if (e.message !== 'Unauthorized') alert('Error: ' + e.message); } } - async function toggleEnabled(i) { const t = {...torrents[i], enabled: !torrents[i].enabled}; try { @@ -762,7 +821,15 @@ async function open2FASetup() { document.getElementById('twofa-error').style.display = 'none'; const qrEl = document.getElementById('twofa-qr-canvas'); qrEl.innerHTML = ''; - new QRCode(qrEl, {text: d.otpauth_uri, width: 200, height: 200}); + new QRCode(qrEl, { + text: d.otpauth_uri, + width: 256, + height: 256, + colorDark: "#000000", + colorLight: "#ffffff", + correctLevel: QRCode.CorrectLevel.H, + margin: 2, + }); $('#twofa-modal').modal('show'); } catch(e) { if (e.message !== 'Unauthorized') alert('Error: ' + e.message); @@ -958,6 +1025,9 @@ $(document).on('click', '.browser-item', function() { let uploadFiles = []; let uploadCancelled = false; +$("#upload-modal").modal({ closable: false }); +document.getElementById("u-btn-close").disabled = true; +document.getElementById("u-btn-close").classList.add("disabled"); let uploadIncludeFolder = true; const UPLOAD_CHUNK_SIZE = 4 * 1024 * 1024; @@ -970,12 +1040,16 @@ function effectiveRelPath(relPath) { function openUploadModal() { uploadFiles = []; uploadCancelled = false; + $("#upload-modal").modal({ closable: false }); + document.getElementById("u-btn-close").disabled = true; + document.getElementById("u-btn-close").classList.add("disabled"); uploadIncludeFolder = true; document.getElementById('u-include-folder').checked = true; document.getElementById('u-file-input').value = ''; document.getElementById('u-folder-input').value = ''; document.getElementById('u-file-summary').textContent = ''; showUploadPhase('select'); + enableModalClosing(); renderUploadFileList(); $('#upload-modal').modal('show'); } @@ -987,6 +1061,15 @@ function showUploadPhase(phase) { document.getElementById('u-btn-close').textContent = phase === 'select' ? 'Cancel' : 'Close'; } +function enableModalClosing() { + $("#upload-modal").modal({ closable: true }); + const btn = document.getElementById("u-btn-close"); + if (btn) { + btn.disabled = false; + btn.classList.remove("disabled"); + } +} + function onUploadFilesSelected(input) { for (const file of input.files) { const relPath = file.webkitRelativePath || file.name; @@ -1026,17 +1109,120 @@ function renderUploadFileList() { summary.textContent = `${uploadFiles.length} file${uploadFiles.length !== 1 ? 's' : ''} — ${fmtBytes(totalBytes)} total`; } +function sha256Pure(data) { + const K = [ + 0x428a2f98,0x71374491,0xb5c0fbcf,0xe9b5dba5,0x3956c25b,0x59f111f1,0x923f82a4,0xab1c5ed5, + 0xd807aa98,0x12835b01,0x243185be,0x550c7dc3,0x72be5d74,0x80deb1fe,0x9bdc06a7,0xc19bf174, + 0xe49b69c1,0xefbe4786,0x0fc19dc6,0x240ca1cc,0x2de92c6f,0x4a7484aa,0x5cb0a9dc,0x76f988da, + 0x983e5152,0xa831c66d,0xb00327c8,0xbf597fc7,0xc6e00bf3,0xd5a79147,0x06ca6351,0x14292967, + 0x27b70a85,0x2e1b2138,0x4d2c6dfc,0x53380d13,0x650a7354,0x766a0abb,0x81c2c92e,0x92722c85, + 0xa2bfe8a1,0xa81a664b,0xc24b8b70,0xc76c51a3,0xd192e819,0xd6990624,0xf40e3585,0x106aa070, + 0x19a4c116,0x1e376c08,0x2748774c,0x34b0bcb5,0x391c0cb3,0x4ed8aa4a,0x5b9cca4f,0x682e6ff3, + 0x748f82ee,0x78a5636f,0x84c87814,0x8cc70208,0x90befffa,0xa4506ceb,0xbef9a3f7,0xc67178f2 + ]; + let h = [0x6a09e667,0xbb67ae85,0x3c6ef372,0xa54ff53a,0x510e527f,0x9b05688c,0x1f83d9ab,0x5be0cd19]; + const r = (n, b) => (n >>> b) | (n << (32 - b)); + const bytes = data instanceof Uint8Array ? data : new Uint8Array(data); + const len = bytes.length; + const bitLen = len * 8; + const padLen = ((len % 64) < 56 ? 56 : 120) - (len % 64); + const padded = new Uint8Array(len + padLen + 8); + padded.set(bytes); + padded[len] = 0x80; + const dv = new DataView(padded.buffer); + dv.setUint32(padded.length - 4, bitLen >>> 0, false); + dv.setUint32(padded.length - 8, Math.floor(bitLen / 0x100000000), false); + for (let i = 0; i < padded.length; i += 64) { + const w = new Array(64); + for (let j = 0; j < 16; j++) w[j] = dv.getUint32(i + j * 4, false); + for (let j = 16; j < 64; j++) { + const s0 = r(w[j-15],7) ^ r(w[j-15],18) ^ (w[j-15] >>> 3); + const s1 = r(w[j-2],17) ^ r(w[j-2],19) ^ (w[j-2] >>> 10); + w[j] = (w[j-16] + s0 + w[j-7] + s1) >>> 0; + } + let [a,b,c,d,e,f,g,hh] = h; + for (let j = 0; j < 64; j++) { + const S1 = r(e,6) ^ r(e,11) ^ r(e,25); + const ch = (e & f) ^ (~e & g); + const t1 = (hh + S1 + ch + K[j] + w[j]) >>> 0; + const S0 = r(a,2) ^ r(a,13) ^ r(a,22); + const maj = (a & b) ^ (a & c) ^ (b & c); + const t2 = (S0 + maj) >>> 0; + hh=g; g=f; f=e; e=(d+t1)>>>0; d=c; c=b; b=a; a=(t1+t2)>>>0; + } + h = h.map((v, i) => (v + [a,b,c,d,e,f,g,hh][i]) >>> 0); + } + return h.map(v => v.toString(16).padStart(8,'0')).join(''); +} + async function sha256Hex(buffer) { - const hash = await crypto.subtle.digest('SHA-256', buffer); - return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + if (crypto?.subtle) { + const hash = await crypto.subtle.digest('SHA-256', buffer); + return Array.from(new Uint8Array(hash)).map(b => b.toString(16).padStart(2, '0')).join(''); + } + return sha256Pure(buffer instanceof ArrayBuffer ? new Uint8Array(buffer) : buffer); +} + +async function sha256HexFile(file) { + const chunkSize = 4 * 1024 * 1024; + let offset = 0; + if (file.size > 500 * 1024 * 1024) { + console.log('File too large for client-side hashing, server will verify'); + return ''; + } + try { + const chunks = []; + while (offset < file.size) { + const chunk = file.slice(offset, Math.min(offset + chunkSize, file.size)); + const chunkBuf = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error('Failed to read file chunk')); + reader.readAsArrayBuffer(chunk); + }); + chunks.push(new Uint8Array(chunkBuf)); + offset += chunkSize; + } + const totalLength = chunks.reduce((sum, chunk) => sum + chunk.length, 0); + const combined = new Uint8Array(totalLength); + let position = 0; + for (const chunk of chunks) { + combined.set(chunk, position); + position += chunk.length; + } + return crypto?.subtle + ? Array.from(new Uint8Array(await crypto.subtle.digest('SHA-256', combined.buffer))).map(b => b.toString(16).padStart(2,'0')).join('') + : sha256Pure(combined); + } catch (e) { + console.error('Error reading file for hashing:', e); + return ''; + } } async function startUpload() { - const destBase = document.getElementById('u-dest-folder').value.trim(); - if (!destBase) { alert('Please select a destination folder on the server.'); return; } - if (uploadFiles.length === 0) { alert('No files selected.'); return; } - uploadCancelled = false; - showUploadPhase('upload'); + console.log('[Upload] Starting upload process...'); + try { + const destBase = document.getElementById('u-dest-folder').value.trim(); + console.log('[Upload] Destination folder:', destBase); + if (!destBase) { + alert('Please select a destination folder on the server.'); + return; + } + if (uploadFiles.length === 0) { + alert('No files selected.'); + return; + } + console.log('[Upload] Files to upload:', uploadFiles.length); + console.log('[Upload] File details:', uploadFiles.map(f => ({ + name: f.relPath, + size: f.file.size, + type: f.file.type + }))); + uploadCancelled = false; + $("#upload-modal").modal({ closable: false }); + document.getElementById("u-btn-close").disabled = true; + document.getElementById("u-btn-close").classList.add("disabled"); + showUploadPhase('upload'); let html = ''; for (let i = 0; i < uploadFiles.length; i++) { html += ` @@ -1094,6 +1280,7 @@ async function startUpload() { document.getElementById('u-overall-label').textContent = `${done} / ${uploadFiles.length} files done`; const overallBar = document.getElementById('u-overall-bar'); overallBar.style.width = ((done / uploadFiles.length) * 100) + '%'; + enableModalClosing(); if (done === uploadFiles.length && !uploadCancelled) { overallBar.style.background = '#21ba45'; } @@ -1101,12 +1288,28 @@ async function startUpload() { if (uploadCancelled) { document.getElementById('u-overall-label').textContent = 'Upload stopped.'; } + } catch (e) { + console.error('[Upload] Fatal error in startUpload:', e); + alert('Upload failed: ' + e.message); + showUploadPhase('select'); + enableModalClosing(); + } } - async function uploadSingleFile(file, relPath, destBase, onProgress, onHashProgress) { const dest = destBase.replace(/\/+$/, '') + '/' + relPath; const totalChunks = Math.max(1, Math.ceil(file.size / UPLOAD_CHUNK_SIZE)); - const fileSha256 = await sha256Hex(await file.arrayBuffer()); + + console.log(`Starting upload: ${relPath} (${file.size} bytes, ${totalChunks} chunks)`); + + let fileSha256; + try { + fileSha256 = await sha256HexFile(file); + console.log(`File hash: ${fileSha256 || '(skipped for large file)'}`); + } catch (e) { + console.error('Failed to calculate file hash:', e); + fileSha256 = ''; + } + const initR = await apiFetch('/api/file-upload/init', { method: 'POST', body: JSON.stringify({ dest, size: file.size, chunks: totalChunks, chunk_size: UPLOAD_CHUNK_SIZE, file_sha256: fileSha256 }), @@ -1120,7 +1323,13 @@ async function uploadSingleFile(file, relPath, destBase, onProgress, onHashProgr throw new Error('Cancelled'); } const start = i * UPLOAD_CHUNK_SIZE; - const chunkBuf = await file.slice(start, Math.min(start + UPLOAD_CHUNK_SIZE, file.size)).arrayBuffer(); + const chunk = file.slice(start, Math.min(start + UPLOAD_CHUNK_SIZE, file.size)); + const chunkBuf = await new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = () => resolve(reader.result); + reader.onerror = () => reject(new Error('Failed to read file chunk')); + reader.readAsArrayBuffer(chunk); + }); const sha256 = await sha256Hex(chunkBuf); let lastErr = null; for (let attempt = 0; attempt < 3; attempt++) { @@ -1247,4 +1456,4 @@ async function batchAdd() { } catch(e) { if (e.message !== 'Unauthorized') showError('Error: ' + e.message); } -} \ No newline at end of file +} diff --git a/src/web/index.html b/src/web/index.html index 140a04e..a41d2ed 100644 --- a/src/web/index.html +++ b/src/web/index.html @@ -116,19 +116,11 @@ Add New Torrent - + Name (optional) - - Output .torrent file (optional) - - - - Upload Limit (KB/s, blank = unlimited) - - Protocol @@ -137,69 +129,88 @@ Add New Torrent rtc (WebRTC only) - - - - Torrent Version - - v1 (SHA-1, default) - v2 (SHA-256 Merkle) - hybrid (v1 + v2) - - - - ICE Servers (overrides global, one per line) - - - - RTC Interval (ms, overrides global) - - - - - + + Enabled + + Torrent Metadata File (.torrent) + + + + + + + + + + + + Create torrent if it doesn't exist + + + + Private torrent + + + Data Path (file or folder to seed) - + Trackers (one per line, optional) - + + + Magnet URI (alternative to .torrent file) + + - Torrent Metadata File (.torrent) — type a path, browse, or upload - - - - - - - - + Upload Limit (KB/s, blank = unlimited) + - Magnet URI - + RTC Interval (ms, overrides global) + - - Add - - Cancel + + + Torrent Version + + v1 (SHA-1, default) + v2 (SHA-256 Merkle) + hybrid (v1 + v2) + + For creating torrents + + + ICE Servers (overrides global, one per line) + + + + + Allowed Extensions (comma-separated, blank = all) + + + + + Add + + Cancel + - Upload Files to Server @@ -537,11 +548,11 @@ Thread Count Scan the QR code with Google Authenticator, Authy, or any TOTP-compatible app, then enter the 6-digit code to confirm. - - + + - Manual key: + Manual key: @@ -551,7 +562,6 @@ Thread Count - Activate 2FA @@ -559,6 +569,7 @@ Thread Count Cancel + @@ -586,7 +597,7 @@ Thread Count - +
' + escHtml(f) + '
${escHtml(t.torrent_file)}
' + escHtml(tr) + '
Scan the QR code with Google Authenticator, Authy, or any TOTP-compatible app, then enter the 6-digit code to confirm.
- Manual key: + Manual key: