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
56 changes: 53 additions & 3 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,15 +4,65 @@ All notable changes to RustChan will be documented in this file.

## RustChan 1.3.0

### Added

- Added a guided first-run setup wizard at `/setup` with public/private/local presets, CSRF protection, review-before-write confirmation, initial admin and board creation, Tor/proxy/Secure-cookie options, CAPTCHA and activity-badge toggles, media limits, and backup defaults.
- Added admin maintenance controls to reopen or close the setup wizard later without replacing existing admin credentials.
- Added per-board PDF upload limits, setup/admin controls for enabling PDF uploads, and upload-form hints that show PDF caps separately from image, video, audio, and generic file limits.
- Added a clean `1.3.0` database baseline. Fresh databases now install the baseline directly, matching in-development databases are stamped as `1.3.0`, and partial or unknown schemas fail closed with diagnostics.
- Added schema health checks to database/admin status paths, including structural verification, SQLite quick checks, foreign-key checks, and `rustchan-cli admin db-status`.
- Added admin dashboard summaries for setup state, schema/database health, backups, storage, Tor, dependencies, background jobs, reports, and live/total thread counts.
- Added a footer copy control for the active Tor onion address.

### Improved

- Hardened upload auto-compression so processed images and videos are re-statted and revalidated before a post is finalized, with better cleanup for failed compression, poll validation failures, stale staged files, thumbnails, and partial database rows.
- Improved browser-side auto-compression reliability for extension-only or blank-MIME uploads, Chromium/WebKit video capture timeouts, submit-time guards, retry behavior, and inline compression status messages.
- Improved media validation for PDFs, ADTS AAC, WebM audio, MKV/Matroska video, small FLAC/audio files, invalid thumbnail payloads, empty upload controls, zero-byte named uploads, and cross-board media deduplication.
- Returned semantic post-upload error statuses for JS, no-JS, and XHR submissions, including `413`, `415`, and `422` where appropriate.
- Improved admin and public UI accessibility with stronger labels, IDs, ARIA attributes, inert/hidden modal states, focus behavior, report and confirmation dialogs, catalog menus, NSFW dialog behavior, theme picker behavior, media controls, and mobile form hints.
- Improved Tor/onion operation with Arti `0.43.0`, safer onion admin origin handling, clearer bootstrap/service logs, more accurate hidden-service key path reporting, and stabilized Tor restore tests.
- Improved observability defaults and documentation so `/healthz` stays public and minimal, `/readyz` hides operational details unless `public_readiness_details = true`, and `/metrics` returns `404` unless `public_metrics_enabled = true`.
- Improved upload, thread, admin, and setup tests around size limits, schema state, CSRF handling, setup reopen/close behavior, pending media refresh, and upload error classification.

### Fixed

- Fixed non-image media regressions that affected small supported audio files, valid `.mkv` uploads, `audio/webm` persistence, and audio/video MIME preservation.
- Fixed malformed ADTS AAC acceptance so invalid AAC is rejected before storage or background media jobs are created.
- Fixed PDF limit edge cases so files exactly at the configured cap are accepted while cap-plus-one uploads are rejected.
- Fixed no-JS and pending-media regressions: Firefox no-JS theme changes persist through safe local redirects, and thread update controls expose a stable `data-action="fetch-updates"` hook for pending-media refreshes.
- Fixed YouTube embed thumbnail sizing.
- Fixed legacy `1.3.0` schema drift handling on startup for in-development databases.
- Fixed unintended manual textarea resize handles on RustChan forms.
- Updated admin login button copy.

### Security

- Required CSRF validation for theme changes and board backup actions.
- Made detailed readiness data and unauthenticated Prometheus metrics opt-in to avoid exposing operational internals on internet or onion deployments by default.
- Kept setup password handling from echoing raw admin passwords during review by using a short-lived pending hash token.
- Pinned `rustls-webpki` to a patched release for RUSTSEC-2026-0049 and refreshed the dependency lockfile.

### Documentation

- Updated `README.md` and `SETUP.md` for RustChan `1.3.0`, the new schema baseline, `db-status`, PDF upload limits, setup behavior, and observability endpoint defaults.
- Added a static ChanNet security audit record for future follow-up.
- Refreshed release documentation and current-version references.

### Internal

- Updated Rust dependencies, including Arti/Tor crates, `sha3`, `zip`, `toml`, `tokio`, `axum`, `rustls`, and related lockfile entries.
- Renamed the saved-backup implementation module for clearer ownership.
- Cleaned focused Clippy warnings and kept strict lint coverage passing during the release cycle.

## RustChan 1.2.2

- Replaced browser proof-of-work posting CAPTCHA with server-generated image CAPTCHA challenges.
- Improved secure-cookie handling across HTTP, HTTPS, trusted-proxy HTTPS, admin sessions, board access cookies, CSRF cookies, and owned-post cookies.
- Added no-JS fallbacks and accessibility polish for posting, reporting, catalog actions, board preferences, moderation controls, and own-post edit/delete flows.
- Hardened upload handling for empty file controls, zero-byte named uploads, empty thumbnail payloads, invalid media, and cross-board media deduplication.
- Tightened post upload validation so rejected media now returns semantic HTTP statuses for JS and no-JS submissions, exact-limit PDFs are accepted while cap + 1 PDFs are rejected, and malformed ADTS AAC is rejected before storage or background job creation.
- Fixed late-cycle no-JS and pending-media regressions: Firefox no-JS theme changes persist through safe local redirects, and thread update controls expose a stable `data-action="fetch-updates"` hook for pending-media refreshes.
- Improved activity badge cache behavior, especially on mobile WebKit and browser back/forward navigation.
- Hardened settings validation so invalid config values fail closed instead of silently falling back.
- Squashed the pre-release internal database migration ladder into a clean `1.3.0` baseline schema. Fresh databases now install that baseline directly, structurally matching in-development databases are stamped as schema version `1.3.0`, and partial or unknown schemas fail closed with diagnostics instead of blind migration attempts.
- Polished responsive layout, long-content wrapping, modal focus behavior, ESC handling, touch targets, and light-theme error contrast.
- Updated backup UI metadata handling and dynamic split-part options.
- Refreshed README screenshots and release documentation.
Expand Down
177 changes: 156 additions & 21 deletions src/chan_net/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ use axum::{
Json, Router,
};
use serde_json::json;
use std::sync::Arc;

// ── ChanError ─────────────────────────────────────────────────────────────────
//
Expand Down Expand Up @@ -125,18 +126,20 @@ async fn json_body_limit_error(req: axum::http::Request<axum::body::Body>, next:
// ─── ChanNet API key middleware ───────────────────────────────────────────────

/// Middleware that enforces the pre-shared `X-ChanNet-Key` header on sensitive
/// `ChanNet` endpoints (/chan/refresh and /chan/poll).
/// `ChanNet` endpoints.
///
/// These endpoints were previously unauthenticated. Any process
/// that could reach the `ChanNet` bind address could trigger a full DB snapshot
/// push (refresh) or pull-and-import from a remote node (poll) with no
/// credentials. The API key is configured via `CHAN_NET_API_KEY` / settings.toml.
/// Any process that can reach the `ChanNet` bind address can otherwise trigger
/// snapshot export/import or gateway commands with no credentials. The API key
/// is configured via `CHAN_NET_API_KEY` / settings.toml.
///
/// If `chan_net_api_key` is empty the request is rejected with 403 Forbidden
/// (the feature is intentionally disabled rather than wide open).
async fn verify_chan_api_key(req: axum::extract::Request, next: Next) -> Response {
async fn verify_chan_api_key(
axum::extract::State(expected): axum::extract::State<Arc<str>>,
req: axum::extract::Request,
next: Next,
) -> Response {
use subtle::ConstantTimeEq as _;
let expected = &crate::config::CONFIG.chan_net_api_key;
if expected.is_empty() {
// API key not configured — refuse the request to prevent accidental
// exposure when an operator forgets to set the key.
Expand Down Expand Up @@ -167,9 +170,21 @@ async fn verify_chan_api_key(req: axum::extract::Request, next: Next) -> Respons
/// being rejected as 413 before validation runs. The config default is
/// `128 * 1024`. Do NOT set it below `34_000` bytes.
pub fn chan_router(state: AppState) -> Router {
Router::new()
// ── Status ──────────────────────────────────────────────────────────
.route("/chan/status", get(status::chan_status))
chan_router_with_auth(
state,
Arc::from(CONFIG.chan_net_api_key.as_str()),
CONFIG.chan_net_command_max_body,
CONFIG.chan_net_max_body,
)
}

fn chan_router_with_auth(
state: AppState,
api_key: Arc<str>,
command_max_body: usize,
import_max_body: usize,
) -> Router {
let protected_routes = Router::new()
// ── RustWave gateway — raw JSON in, ZIP data package out ─────────────
//
// The json_body_limit_error middleware is applied *outside* the
Expand All @@ -178,23 +193,143 @@ pub fn chan_router(state: AppState) -> Router {
.route(
"/chan/command",
post(command::chan_command)
.layer(DefaultBodyLimit::max(CONFIG.chan_net_command_max_body))
.layer(DefaultBodyLimit::max(command_max_body))
.layer(middleware::from_fn(json_body_limit_error)),
)
// ── Federation sync — ZIP in, ZIP out ────────────────────────────────
.route("/chan/export", post(export::chan_export))
.route(
"/chan/import",
post(import::chan_import).layer(DefaultBodyLimit::max(CONFIG.chan_net_max_body)),
)
// /chan/refresh and /chan/poll now require X-ChanNet-Key.
.route(
"/chan/refresh",
post(refresh::chan_refresh).layer(middleware::from_fn(verify_chan_api_key)),
)
.route(
"/chan/poll",
post(poll::chan_poll).layer(middleware::from_fn(verify_chan_api_key)),
post(import::chan_import).layer(DefaultBodyLimit::max(import_max_body)),
)
.route("/chan/refresh", post(refresh::chan_refresh))
.route("/chan/poll", post(poll::chan_poll))
.route_layer(middleware::from_fn_with_state(api_key, verify_chan_api_key));

Router::new()
// ── Status ──────────────────────────────────────────────────────────
.route("/chan/status", get(status::chan_status))
.merge(protected_routes)
.with_state(state)
}

#[cfg(test)]
mod tests {
use super::chan_router_with_auth;
use axum::{
body::Body,
http::{header, Request, StatusCode},
};
use std::sync::Arc;
use tower::ServiceExt as _;

const TEST_KEY: &str = "0123456789abcdef0123456789abcdef";

fn chan_test_state() -> crate::middleware::AppState {
let mut state = crate::test_support::app_state();
state.chan_ledger = Some(Arc::new(parking_lot::Mutex::new(
crate::chan_net::ledger::TxLedger::default(),
)));
state
}

fn chan_test_router(state: crate::middleware::AppState) -> axum::Router {
chan_router_with_auth(state, Arc::from(TEST_KEY), 128 * 1024, 10 * 1024 * 1024)
}

#[tokio::test]
async fn protected_chan_routes_reject_missing_and_wrong_key() {
let router = chan_test_router(chan_test_state());
let cases = [
("/chan/command", Body::from(r#"{"type":"full_export"}"#)),
("/chan/export", Body::empty()),
("/chan/import", Body::from(Vec::<u8>::new())),
];

for (path, body) in cases {
let missing = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header(header::CONTENT_TYPE, "application/json")
.body(body)
.expect("missing-key request"),
)
.await
.expect("missing-key response");
assert_eq!(missing.status(), StatusCode::UNAUTHORIZED, "{path}");

let wrong = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri(path)
.header("X-ChanNet-Key", "wrong-key")
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"type":"full_export"}"#))
.expect("wrong-key request"),
)
.await
.expect("wrong-key response");
assert_eq!(wrong.status(), StatusCode::UNAUTHORIZED, "{path}");
}
}

#[tokio::test]
async fn protected_chan_routes_accept_correct_key() {
let state = chan_test_state();
let snapshot = {
let conn = state.db.get().expect("db connection");
crate::chan_net::snapshot::build_snapshot(&conn)
.expect("snapshot")
.0
};
let router = chan_test_router(state);

let command = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/chan/command")
.header("X-ChanNet-Key", TEST_KEY)
.header(header::CONTENT_TYPE, "application/json")
.body(Body::from(r#"{"type":"full_export"}"#))
.expect("command request"),
)
.await
.expect("command response");
assert_eq!(command.status(), StatusCode::OK);

let export = router
.clone()
.oneshot(
Request::builder()
.method("POST")
.uri("/chan/export")
.header("X-ChanNet-Key", TEST_KEY)
.body(Body::empty())
.expect("export request"),
)
.await
.expect("export response");
assert_eq!(export.status(), StatusCode::OK);

let import = router
.oneshot(
Request::builder()
.method("POST")
.uri("/chan/import")
.header("X-ChanNet-Key", TEST_KEY)
.header(header::CONTENT_TYPE, "application/zip")
.body(Body::from(snapshot))
.expect("import request"),
)
.await
.expect("import response");
assert_eq!(import.status(), StatusCode::OK);
}
}
Loading