Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ function setup(token = 'access-1') {
getAccessToken: vi.fn(() => token),
refresh: vi.fn(() => of({ access_token: 'access-2' })),
clearTokens: vi.fn(),
discardFailedSession: vi.fn(() => true),
};
const router = { navigate: vi.fn() };
TestBed.configureTestingModule({
Expand Down Expand Up @@ -76,4 +77,26 @@ describe('authInterceptor', () => {

expect(auth.refresh).toHaveBeenCalledTimes(1);
});

it('retries with the other tab’s access token, not its refresh token', async () => {
const { auth, router, run } = setup('access-stale');
auth.refresh.mockReturnValue(throwError(() => new HttpErrorResponse({ status: 401 })));
auth.discardFailedSession.mockImplementation(() => {
auth.getAccessToken.mockReturnValue('access-from-other-tab');
return false;
});
const next = vi
.fn()
.mockReturnValueOnce(unauthorized('/api/queue'))
.mockReturnValueOnce(of(new HttpResponse({ status: 200 })));

await run(new HttpRequest('GET', '/api/queue'), next);

expect(auth.discardFailedSession).toHaveBeenCalledTimes(1);
expect(next.mock.calls[1][0].headers.get('Authorization')).toBe(
'Bearer access-from-other-tab',
);
expect(auth.clearTokens).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});
});
13 changes: 13 additions & 0 deletions apps/rustnzb/frontend/src/app/core/services/auth.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,4 +138,17 @@ describe('AuthService', () => {
service.clearTokens();
expect(service.authenticated()).toBe(false);
});

it('keeps another tab’s access token when this tab’s refresh was already spent', () => {
localStorage.setItem('access_token', 'access-from-other-tab');
localStorage.setItem('refresh_token', 'refresh-from-other-tab');
service = new AuthService(http as unknown as HttpClient);
(service as unknown as { lastSpentRefresh: string }).lastSpentRefresh = 'spent-here';

expect(service.discardFailedSession()).toBe(false);

expect(service.getAccessToken()).toBe('access-from-other-tab');
expect(service.authenticated()).toBe(true);
expect(localStorage.getItem('refresh_token')).toBe('refresh-from-other-tab');
});
});
8 changes: 6 additions & 2 deletions apps/rustnzb/frontend/src/app/core/services/auth.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,8 +82,12 @@ export class AuthService {
*/
discardFailedSession(): boolean {
const stored = localStorage.getItem(REFRESH_KEY);
if (stored && stored !== this.lastSpentRefresh && this.getAccessToken()) {
this.accessToken.set(stored);
const access = this.getAccessToken();
if (stored && stored !== this.lastSpentRefresh && access) {
// The replacement is the access token another tab stored, never the
// refresh token sitting next to it.
this.accessToken.set(access);
this.verified.set(true);
return false;
}
this.clearTokens();
Expand Down
8 changes: 8 additions & 0 deletions apps/rustnzb/src/handlers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -81,9 +81,14 @@ pub struct HistoryQuery {
/// Case-insensitive substring of the job name.
pub search: Option<String>,
/// Only entries completed within the last N days. Also bounds `stats`.
/// Values above [`MAX_HISTORY_DAYS`] are rejected: `chrono::Duration::days`
/// panics when the span does not fit in an `i64` of milliseconds.
pub days: Option<u32>,
}

/// Largest `days` window `GET /api/history` accepts (about 100 years).
const MAX_HISTORY_DAYS: u32 = 36_500;

#[derive(Deserialize)]
pub struct AddNzbQuery {
pub category: Option<String>,
Expand Down Expand Up @@ -894,6 +899,9 @@ pub async fn h_history_list(
categories.sort();
categories.dedup();

if q.days.is_some_and(|d| d > MAX_HISTORY_DAYS) {
return Err(ApiError::bad_request("days must be at most 36500"));
}
let cutoff = q
.days
.map(|d| chrono::Utc::now() - chrono::Duration::days(i64::from(d)));
Expand Down
11 changes: 11 additions & 0 deletions apps/rustnzb/tests/history_pagination_api.rs
Original file line number Diff line number Diff line change
Expand Up @@ -181,3 +181,14 @@ async fn categories_and_stats_cover_the_whole_window_not_the_page() {
assert_eq!(week["stats"]["failed"], 15);
assert_eq!(week["stats"]["success_pct"], 87);
}

#[tokio::test]
async fn huge_days_query_is_rejected_instead_of_panicking() {
let (state, _dir) = state_with_history();
let uri: axum::http::Uri = "/api/history?days=100000000".parse().expect("uri");
let q: Query<HistoryQuery> = Query::try_from_uri(&uri).expect("query");
match h_history_list(State(state), q).await {
Err(err) => assert_eq!(err.status(), axum::http::StatusCode::BAD_REQUEST),
Ok(_) => panic!("an overflowing days window must be rejected, not listed"),
}
}
2 changes: 1 addition & 1 deletion crates/nzb-web/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ pub use queue_manager::{
DailyStatisticsData, GlobalStatisticsData, HistoryRetryOutcome, QueueManager, QueueSortField,
ServerStatsData, StatisticsPeriodData,
};
pub use startup::{StartupConfig, StartupResult};
pub use startup::{InstanceLock, StartupConfig, StartupResult};
pub use state::AppState;

pub(crate) fn increment_counter(name: &'static str) {
Expand Down
23 changes: 21 additions & 2 deletions crates/nzb-web/src/startup.rs
Original file line number Diff line number Diff line change
Expand Up @@ -100,7 +100,7 @@ pub struct StartupResult {
pub log_buffer: LogBuffer,
/// Held for the lifetime of the process so a second instance on the
/// same data_dir refuses to start.
_instance_lock: InstanceLock,
pub instance_lock: InstanceLock,
}

/// Exclusive lock on `<data_dir>/rustnzb.lock` ensuring only one rustnzb
Expand Down Expand Up @@ -361,7 +361,7 @@ pub async fn initialize(
state,
queue_manager,
log_buffer,
_instance_lock: instance_lock,
instance_lock,
})
}

Expand Down Expand Up @@ -440,6 +440,25 @@ mod tests {
acquire_instance_lock(&data_dir).unwrap();
}

/// Desktop `start_engine` moves `StartupResult.instance_lock` into process
/// state and then drops the rest of the result. The moved lock must still
/// exclude a second instance until that owner drops it.
#[test]
fn moved_instance_lock_still_excludes_a_second_instance() {
let tmp = tempfile::tempdir().unwrap();
let data_dir = tmp.path().join("data");
std::fs::create_dir_all(&data_dir).unwrap();

let lock = acquire_instance_lock(&data_dir).unwrap();
let retained = lock;
assert!(
acquire_instance_lock(&data_dir).is_err(),
"moving the lock out of StartupResult must not release it"
);
drop(retained);
acquire_instance_lock(&data_dir).expect("released on drop");
}

#[test]
fn sanitize_loaded_config_trims_server_fields() {
let mut config = AppConfig::default();
Expand Down
29 changes: 26 additions & 3 deletions desktop/src-tauri/src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -13,12 +13,28 @@ use tracing_subscriber::EnvFilter;
use tracing_subscriber::layer::SubscriberExt;
use tracing_subscriber::util::SubscriberInitExt;

use nzb_web::startup::InstanceLock;
use nzb_web::{LogBuffer, LogBufferLayer, QueueManager, StartupConfig};

/// Keeps the data-dir flock alive independently of the HTTP task.
///
/// `start_engine` returns one of these. Dropping `StartupResult` after the
/// server is spawned used to unlock immediately.
struct RetainedInstanceLock {
lock: InstanceLock,
}

impl RetainedInstanceLock {
fn new(lock: InstanceLock) -> Self {
Self { lock }
}
}

/// Shared state accessible from Tauri commands and background tasks.
struct EngineState {
queue_manager: Arc<QueueManager>,
server_port: u16,
instance_lock: RetainedInstanceLock,
}

/// Determine the platform-appropriate config directory.
Expand Down Expand Up @@ -154,7 +170,11 @@ fn setup_tray(app: &AppHandle) -> anyhow::Result<()> {
}

/// Start the rustnzb engine and HTTP server.
async fn start_engine() -> anyhow::Result<(Arc<QueueManager>, u16)> {
///
/// The returned lock must be kept alive for the process lifetime. Dropping
/// `StartupResult` after moving `state` out used to unlock immediately, so a
/// second desktop instance could start against the same data directory.
async fn start_engine() -> anyhow::Result<(Arc<QueueManager>, u16, RetainedInstanceLock)> {
let config_dir = config_dir()?;
let data_dir = data_dir()?;
let config_path = config_dir.join("config.toml");
Expand Down Expand Up @@ -190,6 +210,8 @@ async fn start_engine() -> anyhow::Result<(Arc<QueueManager>, u16)> {

let port = result.state.config().general.port;
let queue_manager = Arc::clone(&result.queue_manager);
// Take the lock before `result` is dropped by moving `state` below.
let instance_lock = RetainedInstanceLock::new(result.instance_lock);

// Spawn the HTTP server in the background
let state = result.state;
Expand All @@ -205,7 +227,7 @@ async fn start_engine() -> anyhow::Result<(Arc<QueueManager>, u16)> {
tokio::time::sleep(Duration::from_millis(100)).await;
if reqwest::get(&health_url).await.is_ok() {
info!("HTTP server ready on port {port}");
return Ok((queue_manager, port));
return Ok((queue_manager, port, instance_lock));
}
}

Expand All @@ -221,7 +243,7 @@ async fn start() {
.expect("Failed to install rustls CryptoProvider");

// Start the engine and HTTP server
let (queue_manager, port) = match start_engine().await {
let (queue_manager, port, instance_lock) = match start_engine().await {
Ok(result) => result,
Err(e) => {
error!("Failed to start engine: {e}");
Expand All @@ -238,6 +260,7 @@ async fn start() {
.manage(EngineState {
queue_manager,
server_port: port,
instance_lock,
})
.setup(move |app| {
// Create main window pointing at the HTTP server
Expand Down
Loading