Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
3a26e64
fix(queue): flush article checkpoint for active downloads on graceful…
thedancingdeveloper Oct 9, 2026
0a98b6e
fix(startup): refuse second instance on the same data_dir
thedancingdeveloper Oct 9, 2026
8b4341a
fix: stop retry/restart double-counting downloaded bytes
thedancingdeveloper Oct 10, 2026
265f808
fix: count each job's server stats once; live progress as today
thedancingdeveloper Oct 10, 2026
a8f632c
feat: paginate history server-side with filters and stats
thedancingdeveloper Oct 10, 2026
3f28eed
fix(frontend): groups view filter, paging and fetch-poll teardown
thedancingdeveloper Oct 10, 2026
011fa1b
fix(frontend): shared size/speed formatter to EB; real speed limit on…
thedancingdeveloper Oct 10, 2026
78e05ec
fix(frontend): stop concurrent tabs from logging each other out on re…
thedancingdeveloper Oct 10, 2026
0bd98c5
fix(frontend): sequence queue polls so stale responses can't resurrec…
thedancingdeveloper Oct 10, 2026
a1dd73a
fix(frontend): normalise WebDAV hrefs on the Media page
thedancingdeveloper Oct 10, 2026
65b61bf
fix(dav): accept the web UI session token on /dav; never refresh on D…
thedancingdeveloper Oct 10, 2026
1ff399d
fix(logs): reset Logs cursor when the server restarts
thedancingdeveloper Oct 10, 2026
703adae
fix: per-server header watermarks and dedicated browse connections
thedancingdeveloper Oct 10, 2026
db09759
fix(groups): page and aggregate group/header queries in SQL off the a…
thedancingdeveloper Oct 10, 2026
2bf4edb
fix(rss): validate feed filter regex at save time and auto-download m…
thedancingdeveloper Oct 10, 2026
3791192
fix(groups): pick the observation server by enabled and priority
thedancingdeveloper Oct 10, 2026
a163b06
fix: make database migrations atomic with transaction wrapping
thedancingdeveloper Oct 10, 2026
3bfb329
fix(web): send browser security headers and restrict CORS to same-origin
thedancingdeveloper Oct 10, 2026
6a3716f
fix: write history and stats rows in one transaction
thedancingdeveloper Oct 10, 2026
d943194
fix(web): drain progress listeners on shutdown instead of aborting them
thedancingdeveloper Oct 10, 2026
97af62b
fix(groups): return 404 when a group id is unknown
thedancingdeveloper Oct 10, 2026
00b1d60
fix(frontend): stop inlining critical CSS under the page CSP
thedancingdeveloper Oct 10, 2026
358e313
fix(desktop): pin nzb-dispatch 0.2.9 in the tauri lockfile
thedancingdeveloper Oct 10, 2026
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
22 changes: 11 additions & 11 deletions Cargo.lock

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

2 changes: 1 addition & 1 deletion Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -93,7 +93,7 @@ nzb-nntp = { version = "0.2.24", path = "crates/nzb-nntp" }
nzb-core = { version = "0.2.19", path = "crates/nzb-core", features = ["groups-db"] }
nzb-decode = { version = "0.1.5", path = "crates/nzb-decode" }
nzb-news = { version = "0.1.14", path = "crates/nzb-news" }
nzb-dispatch = { version = "0.2.8", path = "crates/nzb-dispatch" }
nzb-dispatch = { version = "0.2.9", path = "crates/nzb-dispatch" }
nzb-postproc = { version = "0.2.8", path = "crates/nzb-postproc" }
mock-nntp-server = { path = "crates/mock-nntp-server" }
rust-par2 = { version = "0.1.3" }
Expand Down
4 changes: 2 additions & 2 deletions apps/rustnzb/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -78,8 +78,8 @@ assets = [
reqwest = { workspace = true, features = ["multipart", "json"] }
serde_json = { workspace = true }
axum = { workspace = true }
tempfile = "3"
arc-swap.workspace = true
tempfile = "3"
arc-swap.workspace = true
nzb-nntp = { workspace = true, features = ["test-support"] }
crc32fast = { workspace = true }
mock-nntp-server = { workspace = true }
Expand Down
3 changes: 3 additions & 0 deletions apps/rustnzb/config.example.toml
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ script_max_output_bytes = 1048576
# fetch_allow_private = false
# fetch_allowed_hosts = ["prowlarr", "192.168.1.0/24", "172.16.0.0/12"]

# Most headers kept per group when fetching (oldest pruned first). 0 keeps all.
# group_max_headers = 100000

# NNTP servers — add as many as needed, ordered by priority
# Use the web UI "Servers" tab to add/edit servers, or uncomment below.
# Only `host` is required. Omitted fields default to: port 563, ssl = true,
Expand Down
8 changes: 8 additions & 0 deletions apps/rustnzb/frontend/angular.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,14 @@
},
"configurations": {
"production": {
"optimization": {
"scripts": true,
"styles": {
"minify": true,
"inlineCritical": false
},
"fonts": true
},
"budgets": [
{
"type": "initial",
Expand Down
6 changes: 6 additions & 0 deletions apps/rustnzb/frontend/src/app/app.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,4 +87,10 @@ describe('bare route detection', () => {
expect(isBareRoute('/settings')).toBe(false);
expect(isBareRoute('/welcomes')).toBe(false);
});

it('formats sizes and speeds beyond TB without "undefined" (BUG-120)', () => {
const { app } = makeApp();
expect(app.formatBytes(1.1 * 1024 ** 5)).toBe('1.1 PB');
expect(app.formatSpeed(2 * 1024 ** 4)).toBe('2.0 TB/s');
});
});
13 changes: 3 additions & 10 deletions apps/rustnzb/frontend/src/app/app.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { filter } from 'rxjs';
import { ApiService } from './core/services/api.service';
import { AuthService } from './core/services/auth.service';
import { StatusResponse } from './core/models/queue.model';
import { formatBytes, formatSpeed } from './core/format';
import { AddNzbService } from './core/services/add-nzb.service';
import { WidthModeService } from './core/services/width-mode.service';
import { PauseStateService } from './core/services/pause-state.service';
Expand Down Expand Up @@ -547,18 +548,10 @@ export class App implements OnInit, OnDestroy {
}

formatSpeed(bps: number): string {
if (bps === 0) return '0 B/s';
const k = 1024;
const sizes = ['B/s', 'KB/s', 'MB/s', 'GB/s'];
const i = Math.floor(Math.log(bps) / Math.log(k));
return parseFloat((bps / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
return formatSpeed(bps);
}

formatBytes(bytes: number): string {
if (!bytes) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
return formatBytes(bytes);
}
}
46 changes: 46 additions & 0 deletions apps/rustnzb/frontend/src/app/core/format.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { formatBytes, formatBytesParts, formatSpeed } from './format';

describe('formatBytes', () => {
it('walks the unit ladder up to EB (BUG-120)', () => {
expect(formatBytes(0)).toBe('0 B');
expect(formatBytes(1)).toBe('1 B');
expect(formatBytes(1023)).toBe('1023 B');
expect(formatBytes(1024)).toBe('1.0 KB');
expect(formatBytes(1024 ** 2)).toBe('1.0 MB');
expect(formatBytes(1024 ** 3)).toBe('1.0 GB');
expect(formatBytes(1024 ** 4)).toBe('1.0 TB');
expect(formatBytes(1024 ** 5)).toBe('1.0 PB');
expect(formatBytes(1024 ** 6)).toBe('1.0 EB');
});

it('clamps at EB instead of returning undefined (BUG-120)', () => {
expect(formatBytes(4096 * 1024 ** 6)).toBe('4096.0 EB');
});

it('promotes when a value rounds up to the next unit', () => {
expect(formatBytes(1024 ** 2 - 1)).toBe('1024.0 KB');
expect(formatBytes(1024 ** 3 - 1)).toBe('1024.0 MB');
});

it('treats non-finite and non-positive sizes as zero', () => {
expect(formatBytes(Number.NaN)).toBe('0 B');
expect(formatBytes(Number.POSITIVE_INFINITY)).toBe('0 B');
expect(formatBytes(Number.NEGATIVE_INFINITY)).toBe('0 B');
expect(formatBytes(-5)).toBe('0 B');
expect(formatBytes(null)).toBe('0 B');
expect(formatBytes(undefined)).toBe('0 B');
});

it('splits value and unit for template interpolation', () => {
expect(formatBytesParts(1536)).toEqual({ value: '1.5', unit: 'KB' });
});
});

describe('formatSpeed', () => {
it('appends /s to the formatted size', () => {
expect(formatSpeed(0)).toBe('0 B/s');
expect(formatSpeed(1024)).toBe('1.0 KB/s');
expect(formatSpeed(5 * 1024 ** 4)).toBe('5.0 TB/s');
expect(formatSpeed(Number.NaN)).toBe('0 B/s');
});
});
36 changes: 36 additions & 0 deletions apps/rustnzb/frontend/src/app/core/format.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
/**
* Shared byte-size and speed formatting.
*
* Angular templates cannot see plain functions, so components historically
* grew private copies of this logic. Centralising it here keeps the ladder
* consistent across the app and fixes sizes beyond TB rendering as
* "undefined" (BUG-120).
*/

const UNITS = ['B', 'KB', 'MB', 'GB', 'TB', 'PB', 'EB'] as const;

export interface FormattedSize {
value: string;
unit: string;
}

export function formatBytesParts(bytes: number | null | undefined): FormattedSize {
const n = Number(bytes);
if (!Number.isFinite(n) || n <= 0) {
return { value: '0', unit: 'B' };
}
if (n < 1024) {
return { value: String(Math.round(n)), unit: 'B' };
}
let i = Math.min(UNITS.length - 1, Math.floor(Math.log(n) / Math.log(1024)));
return { value: (n / 1024 ** i).toFixed(1), unit: UNITS[i] };
}

export function formatBytes(bytes: number | null | undefined): string {
const parts = formatBytesParts(bytes);
return `${parts.value} ${parts.unit}`;
}

export function formatSpeed(bytesPerSecond: number | null | undefined): string {
return `${formatBytes(bytesPerSecond)}/s`;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import '@angular/compiler';

import { HttpErrorResponse, HttpRequest, HttpResponse } from '@angular/common/http';
import { TestBed } from '@angular/core/testing';
import { Router } from '@angular/router';
import { afterEach, describe, expect, it, vi } from 'vitest';
import { firstValueFrom, of, throwError } from 'rxjs';

import { AuthService } from '../services/auth.service';
import { authInterceptor } from './auth.interceptor';

function setup(token = 'access-1') {
const auth = {
getAccessToken: vi.fn(() => token),
refresh: vi.fn(() => of({ access_token: 'access-2' })),
clearTokens: vi.fn(),
};
const router = { navigate: vi.fn() };
TestBed.configureTestingModule({
providers: [
{ provide: AuthService, useValue: auth },
{ provide: Router, useValue: router },
],
});
const run = (req: HttpRequest<unknown>, next: (r: HttpRequest<unknown>) => unknown) =>
firstValueFrom(
TestBed.runInInjectionContext(() => authInterceptor(req, next as never)),
);
return { auth, router, run };
}

const unauthorized = (url: string) =>
throwError(() => new HttpErrorResponse({ status: 401, url }));

describe('authInterceptor', () => {
afterEach(() => TestBed.resetTestingModule());

it('refreshes and retries API requests that get a 401', async () => {
const { auth, run } = setup();
const next = vi
.fn()
.mockReturnValueOnce(unauthorized('/api/queue'))
.mockReturnValueOnce(of(new HttpResponse({ status: 200 })));

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

expect(auth.refresh).toHaveBeenCalledTimes(1);
expect(next.mock.calls[1][0].headers.get('Authorization')).toBe('Bearer access-2');
});

it.each([
`${window.location.origin}/dav/content`,
'/dav/content/Release/',
'/dav',
])('never spends a refresh token on a 401 from WebDAV (%s)', async (url) => {
const { auth, router, run } = setup();
const next = vi.fn((_req: HttpRequest<unknown>) => unauthorized(url));

await expect(run(new HttpRequest('GET', url), next)).rejects.toMatchObject({ status: 401 });

expect(next).toHaveBeenCalledTimes(1);
expect(next.mock.calls[0]![0].headers.get('Authorization')).toBe('Bearer access-1');
expect(auth.refresh).not.toHaveBeenCalled();
expect(auth.clearTokens).not.toHaveBeenCalled();
expect(router.navigate).not.toHaveBeenCalled();
});

it('treats /api/dav/... as a normal API request', async () => {
const { auth, run } = setup();
const next = vi
.fn()
.mockReturnValueOnce(unauthorized('/api/dav/status'))
.mockReturnValueOnce(of(new HttpResponse({ status: 200 })));

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

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