Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,20 @@ npm test -- --watch=false
npm run build -- --configuration=production
```

Compatibility and security regression tests are deterministic and must remain
network-independent. Use the focused harness gates while iterating:

```bash
cargo test -p nzb-web --tests --locked
cargo test -p rustnzb --tests --locked
cargo test -p nzb-postproc --tests --locked
```

Golden responses are reviewed as API contract changes: keep dynamic type
markers for timestamps, rates, paths, and generated identifiers, and update
the fixture README when the capture source changes. Do not add credentials,
provider URLs, private hostnames, or personal paths to fixtures or logs.

The containerized task interface in [`ci/run`](ci/run) provides local parity
with selected build tasks. See [docs/DEVELOPMENT.md](docs/DEVELOPMENT.md) for
the supported commands.
Expand Down
39 changes: 36 additions & 3 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 3 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ rustls-pki-types = "1"
quick-xml = "0.41"
regex = "1"
walkdir = "2"
tar = "0.4"
flate2 = "1"
tokio-socks = "0.5"
notify = "7"
Expand All @@ -87,11 +88,11 @@ unicode-normalization = "0.1"
# Shared NZB crates
nzb-web = { version = "0.4.20", path = "crates/nzb-web", features = ["groups-db"] }
nzb-nntp = { version = "0.2.22", path = "crates/nzb-nntp" }
nzb-core = { version = "0.2.16", path = "crates/nzb-core", features = ["groups-db"] }
nzb-core = { version = "0.2.17", path = "crates/nzb-core", features = ["groups-db"] }
nzb-decode = { version = "0.1.2", path = "crates/nzb-decode" }
nzb-news = { version = "0.1.12", path = "crates/nzb-news" }
nzb-dispatch = { version = "0.2.6", path = "crates/nzb-dispatch" }
nzb-postproc = { version = "0.2.6", path = "crates/nzb-postproc" }
nzb-postproc = { version = "0.2.7", path = "crates/nzb-postproc" }
mock-nntp-server = { path = "crates/mock-nntp-server" }
rust-par2 = { version = "0.1.3" }
yenc-simd = { version = "0.1.1" }
Expand Down
10 changes: 10 additions & 0 deletions apps/rustnzb/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ cache_size = 524288000 # 500 MB
log_level = "info"
# log_file = "data/rustnzb.log"
# history_retention = 100 # Number of NZBs to keep in history (omit or 0 = keep all)
# auto_sort_remaining_pct = false # Keep queued items ordered by remaining work
# rss_downloaded_item_expiry_days = 30 # Remove downloaded RSS records after this many days
# Independent post-processing jobs overlap downloads. Separate repair and
# extraction gates allow those stages to overlap safely across different jobs.
# These limits are applied at process start.
Expand All @@ -18,6 +20,12 @@ max_repair_workers = 1
max_extract_workers = 1
direct_unpack = true # Extract RAR volumes while downloading when unrar is available
max_nested_archive_depth = 5 # 0=outer archive only; protects against unbounded nesting
# Post-processing hooks run without a shell, with a bounded timeout and output capture.
# scripts_dir = "/data/scripts"
# script_success = "on-success.sh"
# script_failure = "on-failure.sh"
script_timeout_secs = 300
script_max_output_bytes = 1048576

# NNTP servers — add as many as needed, ordered by priority
# Use the web UI "Servers" tab to add/edit servers, or uncomment below:
Expand Down Expand Up @@ -64,6 +72,8 @@ post_processing = 3 # 0=none, 1=repair, 2=unpack, 3=repair+unpack
name = "movies"
output_dir = "movies"
post_processing = 3
# cleanup_patterns = ["*.nfo", "sample/*"]
# unwanted_extensions = [".sfv", ".jpg"]

[[categories]]
name = "tv"
Expand Down
112 changes: 92 additions & 20 deletions apps/rustnzb/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ use serde::{Deserialize, Serialize};
static HTTP_CLIENT: std::sync::LazyLock<reqwest::Client> = std::sync::LazyLock::new(|| {
reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.redirect(reqwest::redirect::Policy::none())
.build()
.expect("Failed to build shared HTTP client")
});

const MAX_NZB_DECOMPRESSED_BYTES: u64 = 100 * 1024 * 1024;
const MAX_FETCH_BODY_BYTES: usize = 100 * 1024 * 1024;

#[cfg(feature = "webdav")]
use nzb_web::nzb_core::config::DavConfig;
Expand All @@ -32,7 +32,9 @@ use nzb_web::nzb_core::nzb_parser;
use nzb_web::nzb_core::sabnzbd_import;

use nzb_web::error::ApiError;
use nzb_web::fetch_guard::{build_fetch_client, read_response_bytes_limited, validate_fetch_url};
use nzb_web::fetch_guard::{
MAX_FETCH_BODY_BYTES, build_fetch_client, read_response_bytes_limited, validate_fetch_url,
};
use nzb_web::log_buffer::LogEntry;
use nzb_web::state::AppState;

Expand Down Expand Up @@ -91,6 +93,17 @@ pub struct MoveJobBody {
pub position: usize,
}

#[derive(Deserialize)]
pub struct SortQueueBody {
/// Sort in ascending remaining percentage order when true.
#[serde(default = "default_sort_ascending")]
pub ascending: bool,
}

fn default_sort_ascending() -> bool {
true
}

#[derive(Deserialize, Serialize)]
pub struct HistoryRetentionBody {
pub retention: Option<usize>,
Expand Down Expand Up @@ -370,7 +383,9 @@ fn enqueue_nzb(

let qm = &state.queue_manager;
job.work_dir = qm.incomplete_dir().join(&job.id);
job.output_dir = qm.complete_dir().join(&job.category).join(&job.name);
job.output_dir = qm
.output_dir_for(&job.category, &job.name)
.map_err(ApiError::from)?;

std::fs::create_dir_all(&job.work_dir).map_err(|e| {
ApiError::from(anyhow::anyhow!(
Expand Down Expand Up @@ -454,6 +469,17 @@ pub async fn h_queue_set_priority(
Ok(Json(SimpleResponse { status: true }))
}

/// POST /api/queue/sort -- Stable sort by remaining work percentage.
pub async fn h_queue_sort(
State(state): State<Arc<AppState>>,
Json(body): Json<SortQueueBody>,
) -> Result<Json<SimpleResponse>, ApiError> {
state
.queue_manager
.sort_by_remaining_percentage(body.ascending);
Ok(Json(SimpleResponse { status: true }))
}

// ---------------------------------------------------------------------------
// Add URL handler
// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -681,15 +707,11 @@ pub async fn h_history_retry(
.map_err(ApiError::from)?
.ok_or_else(|| ApiError::from(anyhow::anyhow!("No NZB data stored for this entry")))?;

// Re-parse the NZB
let mut job = nzb_parser::parse_nzb(&entry.name, &nzb_data).map_err(ApiError::from)?;

job.category = entry.category.clone();

// Set working directories
let qm = &state.queue_manager;
job.work_dir = qm.incomplete_dir().join(&job.id);
job.output_dir = qm.complete_dir().join(&job.category).join(&job.name);
let retry_data = qm.history_get_retry_data(&id).map_err(ApiError::from)?;
let job = qm
.prepare_retry_job(&entry, &nzb_data, retry_data.as_deref())
.map_err(ApiError::from)?;

std::fs::create_dir_all(&job.work_dir).map_err(|e| {
ApiError::from(anyhow::anyhow!(
Expand Down Expand Up @@ -1214,14 +1236,17 @@ pub async fn h_disk_guards_get(
}))
}

/// PUT /api/config/disk-guards -- Update disk guard settings (persisted; restart to apply).
/// PUT /api/config/disk-guards -- Update disk guard settings.
pub async fn h_disk_guards_set(
State(state): State<Arc<AppState>>,
Json(body): Json<DiskGuardsBody>,
) -> Result<Json<SimpleResponse>, ApiError> {
let mut config = (*state.config()).clone();
config.general.min_free_space_bytes = body.min_free_space_bytes;
config.general.abort_hopeless = body.abort_hopeless;
state
.queue_manager
.set_min_free_space(body.min_free_space_bytes);
state.update_config(config).map_err(ApiError::from)?;
Ok(Json(SimpleResponse { status: true }))
}
Expand Down Expand Up @@ -1357,11 +1382,10 @@ pub async fn h_rss_item_download(
}

job.work_dir = state.queue_manager.incomplete_dir().join(&job.id);
job.output_dir = if let Some(ref cat) = item.category {
state.queue_manager.complete_dir().join(cat).join(&job.name)
} else {
state.queue_manager.complete_dir().join(&job.name)
};
job.output_dir = state
.queue_manager
.output_dir_for(&job.category, &job.name)
.map_err(ApiError::from)?;

std::fs::create_dir_all(&job.work_dir).map_err(|e| {
ApiError::from(anyhow::anyhow!(
Expand Down Expand Up @@ -1507,6 +1531,13 @@ pub struct UpdateGeneralBody {
pub max_extract_workers: Option<usize>,
pub history_retention: Option<Option<usize>>,
pub rss_history_limit: Option<Option<usize>>,
pub auto_sort_remaining_pct: Option<bool>,
pub rss_downloaded_item_expiry_days: Option<Option<u64>>,
pub scripts_dir: Option<String>,
pub script_success: Option<String>,
pub script_failure: Option<String>,
pub script_timeout_secs: Option<u64>,
pub script_max_output_bytes: Option<usize>,
}

/// PUT /api/config/general -- Update general settings.
Expand Down Expand Up @@ -1563,6 +1594,48 @@ pub async fn h_general_update(
let _ = state.queue_manager.rss_items_prune(limit);
}
}
if let Some(enabled) = body.auto_sort_remaining_pct {
state.queue_manager.set_auto_sort_remaining_pct(enabled);
config.general.auto_sort_remaining_pct = enabled;
}
if let Some(days) = body.rss_downloaded_item_expiry_days {
config.general.rss_downloaded_item_expiry_days = days;
}
if let Some(directory) = body.scripts_dir {
config.general.scripts_dir = if directory.is_empty() {
None
} else {
Some(directory.into())
};
}
if let Some(script) = body.script_success {
config.general.script_success = if script.is_empty() {
None
} else {
Some(script.into())
};
}
if let Some(script) = body.script_failure {
config.general.script_failure = if script.is_empty() {
None
} else {
Some(script.into())
};
}
if let Some(timeout) = body.script_timeout_secs {
config.general.script_timeout_secs = timeout.max(1);
}
if let Some(max_output) = body.script_max_output_bytes {
config.general.script_max_output_bytes = max_output;
}

state.queue_manager.set_postproc_scripts(
config.general.scripts_dir.clone(),
config.general.script_success.clone(),
config.general.script_failure.clone(),
config.general.script_timeout_secs,
config.general.script_max_output_bytes,
);

state.update_config(config).map_err(ApiError::from)?;
Ok(Json(SimpleResponse { status: true }))
Expand Down Expand Up @@ -1939,9 +2012,8 @@ pub async fn h_import_sabnzbd_api(
)));
}

let json: serde_json::Value = resp
.json()
.await
let body = read_response_bytes_limited(resp, MAX_FETCH_BODY_BYTES).await?;
let json: serde_json::Value = serde_json::from_slice(&body)
.map_err(|e| ApiError::from(anyhow::anyhow!("Invalid JSON from SABnzbd: {e}")))?;

let preview = sabnzbd_import::parse_sabnzbd_api_response(&json);
Expand Down
15 changes: 13 additions & 2 deletions apps/rustnzb/src/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ pub fn build_router(state: Arc<AppState>) -> Router {
.route("/queue/{id}/pause", post(handlers::h_queue_pause))
.route("/queue/{id}/resume", post(handlers::h_queue_resume))
.route("/queue/{id}/move", post(handlers::h_queue_move))
.route("/queue/sort", post(handlers::h_queue_sort))
.route("/queue/{id}/priority", put(handlers::h_queue_set_priority))
.route(
"/queue/{id}/category",
Expand Down Expand Up @@ -321,9 +322,19 @@ pub fn build_router(state: Arc<AppState>) -> Router {
return Err(ApiError::unauthorized());
}

// If no credentials configured, allow all requests (setup_required state)
// Before first-boot setup, only the setup endpoints may be
// reached. Treating the entire protected router as public
// would expose queue/config mutation while the wizard is open.
if !credential_store.has_credentials() {
return Ok(next.run(request).await);
let path = request.uri().path();
if path == "/setup/status"
|| path.starts_with("/setup/")
|| path == "/api/setup/status"
|| path.starts_with("/api/setup/")
{
return Ok(next.run(request).await);
}
return Err(ApiError::unauthorized());
}

// Try Bearer token first
Expand Down
Loading
Loading