diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml index f2c89446..759f7987 100644 --- a/.github/workflows/e2e.yml +++ b/.github/workflows/e2e.yml @@ -83,6 +83,15 @@ jobs: npm ci npx playwright install --with-deps ${{ matrix.browser }} + - name: Configure headless audio output + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends pulseaudio pulseaudio-utils + pulseaudio --start --exit-idle-time=-1 + pactl load-module module-null-sink sink_name=rustchan_ci rate=48000 + pactl set-default-sink rustchan_ci + pactl list short sinks + - name: Build RustChan run: cargo build --locked --bin rustchan-cli @@ -164,6 +173,15 @@ jobs: npm ci npx playwright install --with-deps ${{ matrix.browser }} + - name: Configure headless audio output + run: | + sudo apt-get update + sudo apt-get install -y --no-install-recommends pulseaudio pulseaudio-utils + pulseaudio --start --exit-idle-time=-1 + pactl load-module module-null-sink sink_name=rustchan_ci rate=48000 + pactl set-default-sink rustchan_ci + pactl list short sinks + - name: Build RustChan run: cargo build --locked --bin rustchan-cli diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index d0280ec0..419887b6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -74,6 +74,9 @@ jobs: - name: Run Clippy run: cargo clippy --locked --workspace --all-targets --all-features + - name: Test native update packaging and signing + run: python3 -m unittest discover -s tools -p "test_update_package.py" + - name: Run tests run: cargo test --locked --workspace --all-features @@ -203,11 +206,19 @@ jobs: ) fi + - name: Package signed-update inputs for native Linux + if: runner.os == 'Linux' && !contains(github.ref_name, '-') + shell: bash + run: python3 tools/update_package.py package --binary target/${{ matrix.target }}/release/rustchan-cli --target ${{ matrix.target }} --version "$GITHUB_REF_NAME" --output dist + - name: Upload artifact uses: actions/upload-artifact@b7c566a772e6b6bfb58ed0dc250532a479d7789f # v6 with: name: rustchan-cli-${{ matrix.asset_suffix }} - path: dist/*.zip + path: | + dist/*.zip + dist/rustchan-update-*.tar.gz + dist/build-info-*.json if-no-files-found: error retention-days: 7 @@ -276,11 +287,38 @@ jobs: cd release-assets sha256sum --check --strict SHA256SUMS.txt + - name: Create draft and sign native update artifacts + if: ${{ !contains(github.ref_name, '-') }} + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUSTCHAN_UPDATE_SIGNING_KEY: ${{ secrets.RUSTCHAN_UPDATE_SIGNING_KEY }} + shell: bash + run: | + set -euo pipefail + test -n "$RUSTCHAN_UPDATE_SIGNING_KEY" + key_file="${RUNNER_TEMP}/rustchan-update-key.pem" + umask 077 + trap 'rm -f "$key_file"' EXIT + printf '%s\n' "$RUSTCHAN_UPDATE_SIGNING_KEY" > "$key_file" + if ! gh release view "$GITHUB_REF_NAME" --json isDraft --jq '.isDraft' | grep -qx true; then + if gh release view "$GITHUB_REF_NAME" >/dev/null 2>&1; then + echo "Refusing to alter an already published release" >&2 + exit 1 + fi + gh release create "$GITHUB_REF_NAME" --draft --title "Release $GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md + fi + release_id="$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" --jq '.id')" + python3 tools/update_package.py sign --directory release-assets --key "$key_file" --release-id "$release_id" --version "$GITHUB_REF_NAME" + rm release-assets/build-info-*.json + - name: Create GitHub Release uses: softprops/action-gh-release@a06a81a03ee405af7f2048a818ed3f03bbf83c7b # v2.5.0 env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: + draft: false + prerelease: ${{ contains(github.ref_name, '-') }} + make_latest: ${{ !contains(github.ref_name, '-') }} tag_name: ${{ github.ref_name }} name: Release ${{ github.ref_name }} body_path: RELEASE_NOTES.md diff --git a/CHANGELOG.md b/CHANGELOG.md index b706bd4e..bd4e3542 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,28 +2,55 @@ All notable changes to RustChan will be documented in this file. -## Unreleased +## RustChan 1.6.0 — 2026-10-01 + +This release includes every integrated change since the published v1.5.0 release: +the container deployment workflow, correctness fixes, terminal and web administration +upgrades, public posting and reading improvements, and verified native software updates. + +### Upgrade notes + +- Back up the complete data directory before upgrading. Existing database structures are verified and stamped for the running 1.6.0 package; Rust 1.91 remains the minimum supported compiler. +- Native self-updates require one-time operator setup on Linux GNU x86_64 or ARM64: separate service identities, the supplied systemd/polkit configuration, a protected updater executable and independently provisioned trusted public key. The updater is operator-managed and is not replaced by application updates. See [Software Updates](docs/software-updates.md). +- Containers, macOS, Windows and custom installations use their deployment tools to install releases. Their admin panel can check releases but does not offer native installation. Keep container data volumes when recreating an instance. +- `/readyz` additionally reports the running package version. It does not expose release availability, updater transactions or retained backup metadata. + +### Added + +- Added the administrator-only Software Updates task, stable-release availability notice and persisted progress/results, integrated into the existing admin workspace and all nine themes. Installation requires the current administrator password, CSRF/same-origin validation, an expiring one-use approval and explicit restart confirmation. +- Added a separate native updater with bounded, fixed-operation local IPC and official-repository release discovery. Ed25519-signed manifests bind release identity, target, compatibility, sizes and hashes; strict archive and ELF checks reject unsafe or mismatched payloads before activation. +- Added mandatory verified pre-upgrade snapshots of the database, configuration, media and private runtime state. Failed activation, migration or readiness restores the previous executable and persistent state together; durable recovery gates startup after interruptions and retains evidence when operator intervention is required. Snapshot metadata appears in the existing Backups task. +- Added deterministic Linux update packaging, signed manifests/public-key assets and signing tests to the release workflow while retaining the four existing platform ZIPs and checksums. Stable publication requires the repository signing secret and verifies both native targets before publishing. +- Added a production container image, persistent-volume Compose setup and GHCR publishing for Linux amd64/arm64. Main and stable tags publish `latest`, version tags and immutable commit tags; Compose supports pinning a chosen image. ### Improved -- Expanded the terminal administration console with task-oriented browsing, contextual forms and dialogs, logs, history, telemetry, and responsive operator views. -- Reorganized the web admin panel into 21 task-focused sections with searchable configuration controls, account management, and separate active, saved, overridden, and next-restart settings states. -- Standardized admin spacing, typography, controls, tables, status treatments, and expandable details across all nine existing themes, including loading, error, empty, and disabled states. -- Improved responsive admin forms and moderation layouts down to 320px. Dense wide tables retain horizontal scrolling within named, keyboard-focusable containers, with accessible keyboard navigation and reachable actions. -- Improved current-section navigation, visible focus indicators, contrast, status announcements, and backup progress semantics. Backup dialogs now contain keyboard focus and restore focus when closed, while retaining no-JavaScript fallbacks and reduced-motion support. +- Expanded the terminal administration console with task-oriented browsing, contextual forms and dialogs, logs, history, telemetry, help and responsive operator views. +- Reorganized web administration into 22 task sections with searchable configuration controls and account management. Settings distinguish active, saved, environment-overridden and next-restart values; validated persistence retains operator comments and applies live settings only after successful saves. +- Standardized admin spacing, typography, controls, tables, state treatments and expandable details across all nine themes. Responsive forms work down to 320px; dense tables scroll within named keyboard-focusable regions, with reachable actions and visible focus. +- Improved current-section navigation, contrast, status announcements and backup progress semantics, while preserving native forms, no-JavaScript fallbacks and reduced-motion support. +- Refreshed the README, setup and container guides, demonstration screenshots, and operator documentation for the terminal/admin workflows and native update boundary. ### Fixed -- Rejected network and runtime configuration forms now retain the administrator's selected theme and submitted values. -- Admin health polling no longer starts requests from an unloading page: polling schedules after completion, cancels and aborts on navigation, ignores late updates, and resumes after back/forward-cache restoration. This fixes intermittent WebKit access-control diagnostics without increasing timeouts or enabling retries. -- Firefox launches automatically through the normal browser harness on macOS 27 despite Playwright's upstream shared-application-identity defect ([Playwright #42768](https://github.com/microsoft/playwright/issues/42768)). The isolated identity uses the installed browser binary, requires no manual shell setup, and is protected by launch regressions and a version guard. -- Browser fixture teardown now closes pages before stopping their server, preventing background polling from racing shutdown. Navigation assertions wait for the actual submission response and audits inspect the selected task's visible controls. +- Completed native updates clear the availability notice; stale discovery records cannot offer installation of the running or an older version. Rollbacks retain notice of a newer release. + +- Banned identities can no longer submit reports or poll votes; those mutations share the existing posting ban gate and enforce CSRF before authorization results are disclosed. Long ban reasons wrap on narrow screens. +- NSFW consent and mobile preferences dialogs move and contain keyboard focus, support dismissal/reopening and restore focus to their triggers. Mobile preferences also restore visible trigger focus after returning to desktop. +- Rejected public posts retain submitted drafts and poll settings, explain attachment reselection and provide copyable recovery after parsed errors and bounded oversized uploads. Enhanced CAPTCHA refresh preserves the composer and files. +- Interrupted uploads and unconfirmed responses retain input and explain check-before-retry recovery. Pending uploads cannot fall through into duplicate native submissions, and server-rendered recovery text takes precedence over stale autosave. +- Local quotes and backlinks preserve browser history; dense previews keep their triggers reachable. Search links open complete thread context and retain modified-click behavior; poll result anchors resolve correctly. +- Filename sanitization preserves bounded extensions and Unicode stems. Failed audio/video playback provides readable recovery advice and an original-file download action; explicit scrolling honors reduced motion. +- Catalog “last reply” sorting includes sage replies independently of bump order, preserving normal bump sorting and saved preferences. +- Rejected network and runtime configuration forms retain the administrator's selected theme and submitted values without partially changing saved settings. +- Admin health polling aborts on navigation, ignores late responses, schedules after completion and resumes after back/forward-cache restoration, preventing unloading-page WebKit diagnostics. ### Testing and maintenance -- Restored version-controlled Playwright tests, configurations, npm manifest and lockfile, helper scripts, and browser CI. Browser downloads, profiles, runtime databases, caches, reports, screenshots, traces, videos, and disposable audit evidence remain ignored. -- Coordinated CLI integration-test subprocess spawning to prevent Linux fork/exec descriptor inheritance from making freshly copied executables fail with `ETXTBSY`, while keeping test execution parallel. Kept configuration documentation compatible with Rust 1.91's strict Clippy checks. -- Added deterministic polling lifecycle, Firefox launch and no-JavaScript, fixture disposal, and narrow-table accessibility regressions. Completed validation passed 2,430 Rust tests and the seven-project browser matrix (1,077 passed, 883 explicit skips, zero unexpected failures or retries); WebKit stress testing passed 120/120 with two workers. +- Restored version-controlled Playwright infrastructure and browser CI, with normal regression and weekly/manual deep modes. Runtime data, browser profiles, screenshots, reports, traces and disposable audit evidence remain ignored. +- Added administrator authorization, updater signing/archive/snapshot/rollback/recovery, readiness privacy, all-theme/narrow-screen, keyboard focus and no-JavaScript regressions. Release candidates receive strict Rust lint, locked dependency policy, packaging and browser validation. +- Stabilized fixture teardown, backup completion and completed-redirect submission assertions and session-revocation fixtures that retain and verify the old session without unrelated polling, preserving test budgets and all-theme coverage. Linux browser CI provides a virtual audio output for genuine native playback checks. Firefox uses an isolated application identity for the macOS 27 upstream Playwright launch defect, with launch regressions and a version guard. +- Coordinated CLI test subprocess spawning to prevent Linux fork/exec descriptor inheritance from causing `ETXTBSY`, while preserving parallel test execution. Guarded native updater helpers with Unix configuration so check-only Windows builds remain warning-free. Kept configuration documentation compatible with Rust 1.91 strict lint and refreshed the yanked `yoke-derive` lockfile entry. ## RustChan 1.5.0 — 2026-09-28 diff --git a/Cargo.lock b/Cargo.lock index 5dc58d73..626d5433 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1440,7 +1440,7 @@ dependencies = [ "quote", "sha3 0.12.0", "strum", - "syn 3.0.6", + "syn 2.0.119", "unicode-ident", "void", ] @@ -4188,7 +4188,9 @@ checksum = "16a1cfa75cc186dd73d5818e510e042e40927bccc9c236b061cea97e1eb08029" dependencies = [ "base64 0.23.1", "bytes", + "futures-channel", "futures-core", + "futures-util", "http", "http-body", "http-body-util", @@ -4324,7 +4326,7 @@ dependencies = [ [[package]] name = "rustchan" -version = "1.5.0" +version = "1.6.0" dependencies = [ "anyhow", "argon2", @@ -4338,6 +4340,7 @@ dependencies = [ "crossterm", "dashmap", "data-encoding", + "flate2", "futures", "getrandom 0.4.3", "hex", @@ -4355,17 +4358,20 @@ dependencies = [ "rcgen 0.14.10", "regex", "reqwest", + "ring", "rusqlite", "rustix", "rustls", "rustls-acme", "rustls-pki-types", "rustls-webpki", + "semver", "serde", "serde_json", "sha2 0.11.0", "sha3 0.12.0", "subtle", + "tar", "tempfile", "thiserror 2.0.20", "time", @@ -5218,6 +5224,16 @@ version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" +[[package]] +name = "tar" +version = "0.4.46" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" +dependencies = [ + "filetime", + "libc", +] + [[package]] name = "tempfile" version = "3.27.0" @@ -7574,9 +7590,9 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" +checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 861788e9..80d59473 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustchan" -version = "1.5.0" +version = "1.6.0" edition = "2021" # Keep the package MSRV and cargo-msrv metadata aligned with Arti's Rust 1.91 # requirement. @@ -32,6 +32,10 @@ panic = "abort" name = "rustchan-cli" path = "src/main.rs" +[[bin]] +name = "rustchan-updater" +path = "src/bin/rustchan-updater.rs" + [lib] name = "chan" path = "src/lib.rs" @@ -60,6 +64,13 @@ toml = "1.1.5" argon2 = "0.5.3" sha2 = "0.11.0" +# Signed update verification reuses the TLS provider and existing compression/semver crates. +ring = "0.17.14" +semver = "1.0.28" +flate2 = "1.1.9" +tar = { version = "0.4.46", default-features = false } +reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider", "blocking"] } + subtle = "2.6.1" hex = "0.4.3" # rand_core 0.10 updates RustChan's direct RNG trait dependency, but the @@ -161,12 +172,6 @@ ratatui = { version = "0.30.2", default-features = false, features = ["crossterm # Safe in-process filesystem-capacity queries for upload disk-space preflights. rustix = { version = "1.1.4", features = ["fs"] } -[dev-dependencies] -# The CLI runtime integration test needs a TLS-capable HTTP client to drive the -# built binary over HTTPS. Production targets no longer link an HTTP client. -# default-features = false prevents the native-tls backend from being compiled -# in; rustls-no-provider keeps the test build on the pure-Rust TLS stack. -reqwest = { version = "0.13.4", default-features = false, features = ["rustls-no-provider"] } [features] default = ["tls-self-signed"] diff --git a/Dockerfile b/Dockerfile index e1387ff3..b373058c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -31,7 +31,8 @@ RUN apt-get update && apt-get install -y --no-install-recommends \ COPY --from=builder /out/rustchan-cli /usr/local/bin/rustchan-cli -ENV CHAN_HOST=0.0.0.0 \ +ENV RUSTCHAN_CONTAINER=1 \ + CHAN_HOST=0.0.0.0 \ CHAN_PORT=8080 \ CHAN_TOR_SUPPORT=false diff --git a/README.md b/README.md index adab5b21..a28d8a2b 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,7 @@ Conversations are organized into **boards** (topics such as photography or techn RustChan runs as a single app, with a built-in database. You do not need to set up a separate database server. -**Current release:** [v1.5.0](https://github.com/csd113/RustChan/releases/latest). **Docker image:** `ghcr.io/csd113/rustchan:latest`. +**Source version:** v1.6.0. **Downloads:** [latest published release](https://github.com/csd113/RustChan/releases/latest). **Docker image:** `ghcr.io/csd113/rustchan:latest`. ## Quick start @@ -70,12 +70,14 @@ For **Docker Compose, updates, backups, or a public website**, follow the [conta Open the [latest release](https://github.com/csd113/RustChan/releases/latest), download the ZIP for your operating system, and extract it into a folder where the app can save files. +ZIP names include the release version; the v1.6.0 names are shown below. Choose the matching files from the published release you download. + | Your computer | Download | |---|---| -| Linux, Intel or AMD 64-bit | `rustchan-cli-v1.5.0-linux-x86_64.zip` | -| Linux, ARM64 | `rustchan-cli-v1.5.0-linux-arm64.zip` | -| macOS, Apple silicon | `rustchan-cli-v1.5.0-macos-apple-silicon.zip` | -| Windows, Intel or AMD 64-bit | `rustchan-cli-v1.5.0-windows-x86_64.zip` | +| Linux, Intel or AMD 64-bit | `rustchan-cli-v1.6.0-linux-x86_64.zip` | +| Linux, ARM64 | `rustchan-cli-v1.6.0-linux-arm64.zip` | +| macOS, Apple silicon | `rustchan-cli-v1.6.0-macos-apple-silicon.zip` | +| Windows, Intel or AMD 64-bit | `rustchan-cli-v1.6.0-windows-x86_64.zip` | From a terminal in the extracted folder, run: @@ -230,3 +232,5 @@ Browser-test infrastructure and its npm files are local-only and are not include | [Support](SUPPORT.md) | Where to get help and what the project supports | | [Changelog](CHANGELOG.md) | Release history | | [License](LICENSE) | MIT license | + +Administrator-controlled native Linux updates, signed release verification, verified pre-upgrade snapshots and automatic rollback are described in [Software Updates](docs/software-updates.md). Containers and other deployments remain check-only. `/readyz` reports the running package version and never exposes update availability. diff --git a/SETUP.md b/SETUP.md index c873879e..eb5feff4 100644 --- a/SETUP.md +++ b/SETUP.md @@ -701,3 +701,7 @@ rustchan-data/runtime/tor/state/ ``` Back that directory up if the onion address matters. + +### Optional administrator-controlled software updates + +For immutable native Linux version directories, a separate restricted updater service, signing trust setup and automatic backup/rollback, follow [Software Updates](docs/software-updates.md). The existing single-binary service remains deployment-managed until explicitly converted. diff --git a/deploy/systemd/49-rustchan-updater.rules b/deploy/systemd/49-rustchan-updater.rules new file mode 100644 index 00000000..9b6958ab --- /dev/null +++ b/deploy/systemd/49-rustchan-updater.rules @@ -0,0 +1,10 @@ +// Root-owned /etc/polkit-1/rules.d/49-rustchan-updater.rules. +// No shell, arbitrary unit, reload, enable, restart or environment authority. +polkit.addRule(function(action, subject) { + if (subject.user === "rustchan-updater" && + action.id === "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") === "rustchan.service" && + (action.lookup("verb") === "start" || action.lookup("verb") === "stop")) { + return polkit.Result.YES; + } +}); diff --git a/deploy/systemd/rustchan-updater.service b/deploy/systemd/rustchan-updater.service new file mode 100644 index 00000000..1eea5d72 --- /dev/null +++ b/deploy/systemd/rustchan-updater.service @@ -0,0 +1,39 @@ +[Unit] +Description=RustChan restricted signed-release updater +After=network-online.target +Wants=network-online.target + +[Service] +Type=simple +User=rustchan-updater +Group=rustchan-updater +SupplementaryGroups=rustchan +ExecStart=/usr/local/libexec/rustchan-updater +Restart=on-failure +RestartSec=5 +RuntimeDirectory=rustchan-updater +RuntimeDirectoryMode=0750 +StateDirectory=rustchan-updater +StateDirectoryMode=0700 +UMask=0077 +# RustChan enforces owner-only files. These capabilities permit reading snapshots +# and restoring their original owner/mode, confined to the writable paths below. +CapabilityBoundingSet=CAP_DAC_OVERRIDE CAP_CHOWN CAP_FOWNER +AmbientCapabilities=CAP_DAC_OVERRIDE CAP_CHOWN CAP_FOWNER +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/opt/rustchan /var/lib/rustchan-updater /var/lib/rustchan +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 +RestrictNamespaces=true +LockPersonality=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +ProtectKernelLogs=true +ProtectClock=true +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/rustchan.service b/deploy/systemd/rustchan.service new file mode 100644 index 00000000..8db76773 --- /dev/null +++ b/deploy/systemd/rustchan.service @@ -0,0 +1,29 @@ +[Unit] +Description=RustChan managed native imageboard +After=network-online.target rustchan-updater.service +Wants=network-online.target rustchan-updater.service + +[Service] +Type=simple +User=rustchan +Group=rustchan +WorkingDirectory=/var/lib/rustchan +Environment=RUSTCHAN_MANAGED=1 +Environment=CHAN_DB=/var/lib/rustchan/chan.db +Environment=CHAN_UPLOADS=/var/lib/rustchan/boards +Environment=CHAN_HOST=127.0.0.1 +Environment=CHAN_PORT=8080 +ExecStart=/opt/rustchan/current/rustchan-cli --data-dir /var/lib/rustchan serve +Restart=on-failure +RestartSec=5 +TimeoutStopSec=90 +UMask=0077 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ReadWritePaths=/var/lib/rustchan +RestrictAddressFamilies=AF_UNIX AF_INET AF_INET6 + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/updater.toml b/deploy/systemd/updater.toml new file mode 100644 index 00000000..39ea370a --- /dev/null +++ b/deploy/systemd/updater.toml @@ -0,0 +1,10 @@ +# Root-owned 0644, /etc/rustchan/updater.toml. No web-configured paths. +install_dir = "/opt/rustchan" +state_dir = "/var/lib/rustchan-updater" +data_dir = "/var/lib/rustchan" +settings_path = "/var/lib/rustchan/settings.toml" +health_port = 8080 +# Replace with: id -u rustchan +web_uid = 10001 +public_key = "/etc/rustchan/update-public-key.hex" +retention = 3 diff --git a/docs/containers.md b/docs/containers.md index bf25a286..77520f01 100644 --- a/docs/containers.md +++ b/docs/containers.md @@ -64,3 +64,7 @@ For Compose, use `docker compose stop` and `docker compose start` and replace `r ## Images and architectures GHCR image: `ghcr.io/csd113/rustchan`. The publishing workflow builds `linux/amd64` and `linux/arm64`. `latest` tracks the main branch and stable semantic-version tags. Every published commit receives an immutable `sha-` tag. A tag such as `v1.5.0` also produces `1.5.0` and `1.5` tags; the release tag must match the Cargo package version under the repository's release workflow. Pin a SHA tag for a repeatable deployment. Package visibility in GHCR is controlled in GitHub's package settings. + +### Software update checks + +Administrators can check stable RustChan releases in Software Updates. Container images remain deployment-managed; there is no Install control. Pull the chosen image and recreate the container while retaining the whole data volume. `/readyz` includes the running package `version`, without latest-release or update state. See [Software Updates](software-updates.md). diff --git a/docs/public-interface-polish-audit.md b/docs/public-interface-polish-audit.md new file mode 100644 index 00000000..8e47116b --- /dev/null +++ b/docs/public-interface-polish-audit.md @@ -0,0 +1,102 @@ +# Public interface polish audit — 2026-09-30 + +Scope: home, board indexes, catalog, threads, search, archives, preferences, media, and polls. Admin changes were excluded; shared behavior was checked with the maintained regression harness. ChanNet is already removed from this branch; no obsolete infrastructure was restored. + +The initial `interface-audit` working tree was clean. At the audit handoff, all changes were local and uncommitted. No dependencies, framework, route renames, deployment, or visual redesign were introduced. + +## Baseline and findings + +The documented build and maintained `tests/e2e` workflow launched the actual RustChan binary in unique loopback runtimes, with synthetic SQLite data, uploads, credentials, settings, and disabled Tor/background maintenance. Existing protections were examined first: server-side CSRF, one-use CAPTCHA, upload limits, submission-token atomicity, input recovery, native forms, quote highlighting, thumbnail fallbacks, dialog containment, and cookie-backed preferences were retained. + +The initial Chromium posting/upload/idempotency baseline passed all nine selected tests. Findings below are functional defects; no subjective visual changes were necessary. + +| Reproduction | Impact | Contained fix and regression | +| --- | --- | --- | +| Create a native thread with three poll options and a nondefault duration, then submit a wrong CAPTCHA. | Rejection retained body/name/subject but reset the poll. | Retain escaped question/options and original duration controls; reopen the populated poll. `public-polish-native.spec.ts`, forms unit tests. | +| Reject a native post with attachments. | Browser clears file controls after navigation without explaining reselection. | Show attachment reselection advice only when files were submitted; preserve text/sage. Native posting regressions. | +| Submit a parsed reply after its thread is locked/deleted, or with invalid CSRF. | Standalone failure lacked a copyable submitted draft. | Preserve status/security checks and display an escaped readonly draft plus recovery advice; dedicated ban/appeal pages remain intact. Native posting regressions. | +| Reject a native oversized multipart request before parsing finishes. | Firefox could reset the connection; after receiving 413, Back could restore an empty form. | Retain received text and read trailing bounded poll controls for an escaped copyable draft. One shared 8 MiB/1 s discard allowance includes excess upload and remaining controls; no excess media is staged, original rejection and aggregate/field/concurrency limits remain enforced. Native 413/copy/fresh-form/smaller-file retry regression plus cap/timeout tests. | +| Select a file and fill text/poll fields, then choose a new CAPTCHA. | Full-page refresh silently discards unsent choices and attachments. | Refresh only CAPTCHA fields in place with JavaScript, retain the submission token/files/other controls, explain refresh failure; native refresh warns about copying before navigation. Posting regressions. | +| Upload receives an unconfirmed HTTP 200, aborts, times out, or fails synchronously. | Unconfirmed response could reload away input; abort was silent; a synchronous send exception could leave controls busy. | Retain input, restore controls on failure, give an uncertainty/check-before-retry action, and keep success navigation tied to the server redirect. Actual SQLite-delayed request and token-reusing retry regression. | +| Trigger a second submit while an upload is pending. | Busy handler could fall through into native submission. | Prevent pending submissions; keep controls busy through confirmed navigation. Posting regression. | +| Upload progress event has no computable total. | Full progress bar could imply measured completion. | Show unmeasured upload state; announce upload/server-wait status using actual upload completion, without invented server percentages. | +| A validation response returns submitted reply text while another tab has written stale autosave. | Client restore could overwrite the newer server-rendered text. | Prefer nonempty server-rendered text over shared autosave. Posting regression. | +| Activate a local quote/backlink and use browser Back, or hover a quote into a dense post. | Enhanced clicks bypassed native history; an oversized preview could cover its trigger. | Retain native fragment/history navigation and constrain previews to available viewport space. Reading regression. | +| Activate a search result post number or a quote whose target is outside the results. | Links had no complete-thread context and could appear deleted. | Context-aware search links resolve through existing thread/post routes; preserve modified-click navigation. Reading and renderer regressions. | +| Vote in a poll. | Existing `#poll` redirect had no target element. | Add the target and header scroll margin. Reading regression. | +| Upload a filename longer than the existing 100-character bound. | Truncation could remove the extension. | Preserve a bounded extension and Unicode stem with unchanged forbidden-character replacements and length limit. Reading and sanitization regressions. | +| Load a stored audio/video file the browser cannot play, including before the application script finishes loading. | Native controls could remain unusable without an application explanation. | Add a compact announced failure message and original-file download action; recognize existing failure state when wiring and clear it after successful reload. Real WAV/H.264 playback, corruption, download, and recovery regressions; use primary theme text for readable captions. | +| Add a sage reply to an older thread and select catalog “last reply”. | Sorting reused the bump timestamp and ignored the newest reply. | Compute the latest post time in the existing grouped thread query and use it for this catalog choice; preserve bump ordering, image counts, and saved preferences. Catalog and database regressions. | +| Open preferences by keyboard at mobile width, dismiss it, or resize an open panel. | Focus remained outside the overlay; mobile panel lacked dialog semantics/containment. | Use the existing dialog trap with mobile-only metadata, initial Close focus, and restoration after native details has rendered closed. Visual regression. | +| Open a mobile composer with reduced motion enabled. | Explicit smooth scroll ignored the preference. | Shared scroll behavior also covers errors and new-reply navigation. Visual regression. | + +## Evidence and verification + +Generated reports, diagnostics, traces, videos, runtime logs, and before/after screenshots stay ignored under `output/playwright/public-polish/`. Representative screenshots were visually inspected, including native poll recovery, uncertain upload response, dense Unicode threads, search context, portrait plus audio attachments, report dialogs, preferences, and native unavailable/oversized states. + +Built-in themes: Forest, Blue Sky, Deep Orbit, Terminal, DORFic, ChanClassic, Frutiger Aero, NeonCubicle, and FluoroGrid. Each was exercised at 1280×800, 320×720, and 667×320 in the configured browser projects. The theme audit checks document overflow, post/metadata readability, focus, and dialog controls while retaining each theme's appearance. + +Final Rust/source validation: + +| Command | Result | +| --- | --- | +| `cargo fmt --all --check` | Passed. | +| `cargo check --workspace --all-targets --all-features` | Passed. | +| `cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo` | Passed without new lint suppressions. | +| `cargo test --workspace --all-features` | Passed: 1,227 library + 1,227 binary + 5 runtime integration + 1 documentation test = 2,460 executions; none failed or ignored. | +| `cargo build --locked --bin rustchan-cli` | Passed; refreshed embedded CSS/JavaScript for the browser runs. | +| `cargo test --lib --all-features rejected_upload` | Passed all 7 size/field/aggregate/deadline/draft recovery regressions. | +| `cargo test --lib --all-features listing_last_post_includes_non_bumping_text_and_audio_without_counting_them_as_images` | Passed; active/archive aggregates and image counts retained. | +| `node --check static/main.js` and `git diff --check` | Passed. | +| `node tests/e2e/media-toolchain-check.mjs --self-test` | Passed. | +| `node tests/e2e/media-toolchain-check.mjs` | Failed host preflight: FFmpeg lacks `libwebp`; full optional media-toolchain suite not launched or counted as passed. | + +Final browser runs used `RUSTCHAN_E2E_SKIP_BUILD=1`, unique `RUSTCHAN_AUDIT_OUTPUT` directories, the freshly rebuilt binary, and the existing Playwright configuration with zero automatic retries: + +| Maintained-harness selection | Passed | Explicit skips | Failed | Evidence directory | +| --- | ---: | ---: | ---: | --- | +| `npx playwright test tests/e2e/public-polish-native.spec.ts --workers=1` | 56 | 0 | 0 | `native-final-latest/` | +| `npx playwright test tests/e2e/public-polish-posting.spec.ts tests/e2e/public-polish-visual.spec.ts --workers=2` | 67 | 24 | 0 | `posting-visual-final/` | +| `npx playwright test tests/e2e/public-reading-polish.spec.ts tests/e2e/public-catalog-polish.spec.ts --workers=1` | 59 | 4 | 0 | `reading-complete/` | +| `npx playwright test tests/e2e/submission-idempotency.spec.ts tests/e2e/phase3-media-runtime.spec.ts tests/e2e/phase4-empty-error-permission.spec.ts --workers=2` | 21 | 35 | 0 | `shared-final/` | +| `npx playwright test tests/e2e/media.spec.ts --project=chromium --project=webkit --workers=1` | 6 | 0 | 0 | `media-maintained-final/` | +| **Final total** | **209** | **63** | **0** | No browser launch failures or automatic retries. | + +The five new public specs cover all seven configured profiles: Chromium, WebKit, Firefox, mobile Firefox, mobile WebKit, Firefox without JavaScript, and Chromium without JavaScript. Their total is 182 passes and 28 enhancement-only skips. Native posting tests explicitly disable JavaScript in all seven profiles. Real WAV and H.264 native playback was verified in all seven; enhanced corruption/download/recovery runs in the five JavaScript profiles. All nine themes were exercised at desktop, narrow portrait, and short landscape widths; new audio error text/download actions also met the measured 4.5 contrast threshold in all nine themes. + +Additional broader maintained-suite evidence from earlier in implementation: 196 passed / 140 explicit project/mode skips across twelve specs (`regressions/`), plus eight passed / thirteen applicability skips for search, board pagination, preferences, sage and poll journeys (`public-journeys/`). These are separate earlier runs, not added to the final total. Shared error/permission, media and atomic-submission paths were rechecked against the final implementation as listed above. + +Earlier failures remain archived. They include the reproduced Firefox transport/history failures, the DORFic caption contrast defect, an early media failure before script initialization, test fixtures that did not match route body limits, pointer-click focus assumptions in WebKit, and intermittent native modified-click behavior under concurrent runs. Corrected keyboard tests and an unchanged-timeout clean reading matrix passed; click diagnostics verified real Meta input with `defaultPrevented: false`. No browser skips or failed attempts were counted as passes. + +Representative before screenshots: `before-chromium-chanclassic-thread-320.png`, `before-chromium-report-landscape.png`, native recovery captures under `native-before-poll/`, and catalog reproduction under `catalog-before/screenshots/`. Representative inspected after screenshots: + +- `after-native-oversized-chromium.png` and `after-native-oversized-mobile-firefox.png`: received draft/poll and explicit reselection recovery. +- `captcha-refreshed-after-chromium.png` and `uncertain-response-after-chromium.png`: preserved enhanced composer. +- `after-chromium-chanclassic-thread-320.png` and `after-mobile-webkit-preferences-landscape.png`: density, wrapping and reachable focused controls. +- `reading-complete/screenshots/chromium-wav-corrupt-dorfic.png` and `reading-complete/screenshots/mobile-webkit-video-corrupt.png`: readable native-media failure actions. +- `reading-complete/screenshots/firefox-catalog-last-reply.png` and `reading-complete/screenshots/mobile-firefox-search-page-restored.png`: sorting and restored search context. + +All paths in this section are relative to `output/playwright/public-polish/`; each run also retains its `results.json`, HTML report and diagnostics. + +## Changed files + +- `src/db/threads.rs`: latest-post aggregate and database regression. +- `src/models.rs`: latest-post time in the existing thread model. +- `src/handlers/board/create_thread.rs`, `src/handlers/thread.rs`: posting error and draft recovery. +- `src/handlers/mod.rs`: shared bounded multipart recovery and parser regressions. +- `src/handlers/render.rs`: existing test factory updated for the thread field. +- `src/templates/board.rs`, `src/templates/thread.rs`: search context, catalog sort data, poll anchor, renderer regressions and test factories. +- `src/templates/forms.rs`: retained poll/draft controls, native recovery advice and upload status semantics. +- `src/utils/sanitize.rs`: extension-preserving bounded filename sanitization and regressions. +- `static/main.js`, `static/style.css`: posting/CAPTCHA recovery, quote history/previews, mobile dialog focus, reduced-motion scrolling, catalog sorting and readable media failure feedback. +- `tests/e2e/public-polish-posting.spec.ts`, `tests/e2e/public-polish-native.spec.ts`, `tests/e2e/public-reading-polish.spec.ts`, `tests/e2e/public-catalog-polish.spec.ts`, `tests/e2e/public-polish-visual.spec.ts`: maintained-harness public journey regressions. +- `CHANGELOG.md`, `docs/public-interface-polish-audit.md`: change summary, reproductions, evidence and coverage limits. + +## Coverage boundaries + +- Mobile Firefox/WebKit are emulated viewport/touch/user-agent profiles, not physical devices. Physical mobile keyboards, screen-reader announcement order, and browser-native zoom UI were not exercised. +- No-JavaScript CAPTCHA refresh is a document navigation: its visible warning explains copying unsent content first. Browsers cannot programmatically restore file selections into a newly rendered native form. +- Malformed, stalled, or sufficiently large rejected streams may exceed the bounded recovery allowance. Only received controls can be saved; partial recovery says so explicitly. Browser Back is unreliable after some native upload errors, so delivered error pages provide copyable drafts. Abrupt transport failures can prevent an error page from arriving at all. Upload size, aggregate, field, timeout, CSRF, and concurrency checks remain enforced. +- Full optional media-toolchain preflight cannot pass on this host because FFmpeg lacks `libwebp`; it is not counted as a passing codec/transcoding matrix. +- CAPTCHA expiry rejection is checked through the real consumed-challenge HTTP path and existing timestamp-expiry Rust tests. A five-minute elapsed browser expiry wait is not counted as exercised. +- Skips are reported separately from passes. Superseded attempts retain evidence of test-authoring assumptions, stale binary runs, native-details focus timing, and browser interception behavior; they are not final passes or silently retried tests. diff --git a/docs/software-update-audit.md b/docs/software-update-audit.md new file mode 100644 index 00000000..569e8c25 --- /dev/null +++ b/docs/software-update-audit.md @@ -0,0 +1,47 @@ +# Software update integration audit + +Reference: RustPost commit `4fe628fa4f0270ebceecb73b2f01e325942676e8`. + +* RustChan builds `rustchan-cli`, embeds templates/static files, and defaults to + `chan.db`, `settings.toml`, `boards`, and `runtime` under its data directory. + Native update setup must pin these paths; arbitrary/custom layouts stay + deployment-managed. Runtime includes Tor identity, TLS material and managed + assets. Media reconciliation can mutate files during startup, so rollback + snapshots must preserve boards/runtime as well as database/configuration. +* Schema versions are semantic release strings, not RustPost's integers. Existing + initialization verifies/migrates schema transactionally. Manifests declare an + explicit semantic schema range and target schema; readiness uses the new + application's own schema probe. The updater checks SQLite integrity and + foreign keys independently and never runs reverse migrations. +* Only `admin_sessions` authorize administrator routes. Public viewer/account + preferences grant no administrator privileges. All administrators currently + have full site authority; there is no reduced administrator/moderator role. + Existing session-scoped CSRF, same-origin checks, login lockout and credential + confirmation are reused without widening authorization. +* Admin uses one task workspace, a section index, disclosure panels, existing + state pills and responsive forms. Baseline Chromium navigation test passed; + desktop Accounts and mobile Overview screenshots were manually reviewed. + Software Updates belongs in this same shell, with snapshots in Backups. +* Existing manual/scheduled/maintenance backups use rustchan-backup-v4 manifests + and private files. Updater-owned rollback snapshots must remain inaccessible + to the web identity, including deletion/restoration. Their verified metadata is + integrated through authenticated IPC into the existing Backups workspace; + manual restore controls retain their existing protection. +* MaintenanceGate serializes web maintenance/background work. Native updater + coordinates by stopping the fixed service before its final snapshot; HTTP + mutations fail closed while activation/recovery is in progress. Discovery does + not stop the service or block writes. Startup must consult updater recovery + before opening/migrating the database or reconciling persistent files. +* Health endpoints are `/healthz` and `/readyz`, not `/ready`. Readiness is public + and minimal by default, with optional existing operator details. Add only the + running version; no update availability/metadata enters public responses. +* Native Linux deployment documentation currently uses systemd with an + unprivileged rustchan identity; containers run UID 10001 and persist /data. + Containers/macOS/Windows remain check-only. Unix socket peer UID checks and + fixed service actions are Linux-native only. +* Releases use v and preserve ZIP archives for Linux x86_64, + Linux aarch64, Apple Silicon and Windows x86_64. Signed update artifacts are + additional Linux assets; the updater remains operator-managed. Container latest + currently follows main; prerelease tags must not advance latest. +* Existing seven-project Playwright suite has isolated runtimes, real admin forms, + diagnostics, theme/mobile/no-JS coverage. New browser coverage uses that harness. diff --git a/docs/software-update-ui-release-audit.md b/docs/software-update-ui-release-audit.md new file mode 100644 index 00000000..b64cc398 --- /dev/null +++ b/docs/software-update-ui-release-audit.md @@ -0,0 +1,248 @@ +# Software Updates UI and v1.6.0 release audit + +Audit date: 2026-10-01. Candidate repository: `csd113/RustChan`. + +## Prerequisite and scope + +The actual updater implementation task was `01a0f571-4bb7-7792-bd00-42f61a935f3d` +(`Read pasted text file`), completed turn `01a0f571-5bf6-7b31-9ff0-7b05af358744`. +Its final handoff is retained locally at `output/software-update-handoff.txt`. +The independent audit began only after that task completed and its RustChan +processes and validations stopped. The older public-interface audit was a separate +completed task. Unrelated project processes were left alone. + +The release comparison is the actual published `v1.5.0` through the candidate +history and completed updater changes. The intended release branch is `main`. +Notes cover the integrated container, correctness, terminal, admin, public posting/ +reading, browser infrastructure and updater work; no unmerged branch is listed as +shipped. Cargo and its lockfile are prepared for `1.6.0`; README screenshot +provenance remains its actual v1.5.0 Docker capture. + +## Browser audit + +A real headed Chromium session used a disposable loopback server with a copied +binary, temporary data directory, test credentials and container/check-only mode. +Native confirmation was injected from the exact production Rust renderer into the +authenticated real admin shell. Install submissions were blocked or intercepted; +no production server or native updater was invoked. The maintained Playwright +regressions use the same isolated data and exact native-renderer approach. + +All nine themes were checked at 1280, 390 and 320px: Forest, Blue Sky, Deep Orbit, +Terminal, DORFic, ChanClassic, Frutiger Aero, NeonCubicle and FluoroGrid. Both +deployment-managed and verified native forms fit their cards without document +overflow; password, confirmation and actions remain reachable. Focus outlines +measure at least 2px; mobile action heights measure 42px. Safari's default keyboard +preference uses Option-Tab to reach buttons; regular input Tab order is preserved. +Incorrect confirmation is rejected, and the original session/CSRF/password gates +remain in place. The layout needed no style changes. A real success-state +regression did require a focused behavior fix: the native transaction retains +its discovery journal after installation, so matching `Available` alone left the +admin notice visible for the version now running. A shared stable-version check +now gates both the notification and native install control. Current, older, +prerelease and malformed candidates cannot offer a stale installation; rollback +still shows notice of a genuinely newer release. A failing browser regression +captured the old behavior before the correction. + +Availability, no update, rejected verification, unavailable release checks, +unsupported/container deployments, all eleven active transaction phases, four +terminal outcomes, idle state and malformed persisted state were exercised. +Long failure text wraps at 320px. The actual release check is a native document +POST/navigation, not a separate synthetic `checking` transaction phase; an +intercepted pending check and failed-check retry were exercised without reaching +GitHub. Enhanced success/rollback and real temporary server outages/reconnection +use persisted state; native POST/reload paths are exercised without JavaScript. + +Admin-only disclosure was verified on the authenticated panel and status/check/ +install routes. Anonymous contexts and forged public-session cookies cannot read +update data. RustChan has no ordinary authenticated account or reduced moderator +role: only full administrator sessions authorize these routes. Public `/readyz` +reports only readiness and the running package version; the application endpoint +is `/readyz`, not `/ready`. + +The surrounding audit covers all 22 admin tasks, narrow local table scrolling, +dashboard/state contrast, configuration navigation/persistence, moderation, +polling lifecycle and manual backup/restore. A real small backup in the disposable +manual instance opened its existing progress dialog: initial focus and Tab/Shift- +Tab containment were verified at 320px, and Done closed it and reloaded Backups. +Escape does not dismiss that existing progress dialog; this is not recorded as a +passed Escape-dismissal test. Updater snapshots remain metadata-only in the +existing Backups task. + +## Evidence + +Local ignored evidence is under `output/playwright/v160-ui-audit/`: + +- `before/`: 54 theme/width screenshots, nine native-card captures, a visually + reviewed contact sheet, manual measurements and a backup-dialog screenshot. +- `regressions/`, `harness-recheck/`, `native-keyboard-recheck/`: initial expanded + attempts and their traces are retained. Added audit-fixture failures were + corrected by forcing real document navigation, using the actual engine fixture + for WebKit keyboard behavior, and beginning functional submission from a fresh + document after the viewport matrix. Assertions, timeouts and retries were not + relaxed. The later stale-notice regression required the focused semantic fix above. +- `final-matrix/`: the combined surrounding-admin/public release audit, 235 passed, + 87 explicit conditional skips, no failures (322 executions). This run preceded + the later stale-notice correction. +- `updater-final-state/`: corrected updater and real backup/restore, 68 passed, + two intentional no-JavaScript reconnect skips, no failures (70 executions). +- `check-submission-final-state/`: final native check pending/failure/retry, + seven passed across every browser profile, no failures. +- `validation-final-state/`: final source commands/results; previous attempts are + retained in `validation/`. + +The theme images retain the existing layout. `stale-badge-before/` contains the +failing assertion, DOM trace and screenshot for the completed-release notice; +`updater-final-state/` contains the corresponding corrected result and rollback +regression. The left index is independently scrollable, so the full-page screenshot +alone does not expose every navigation entry; the assertion and trace establish +badge presence. No unrelated visual redesign is claimed. + +## Validation and release gate + +Final source validation passed: + +| Gate | Result | +| --- | --- | +| `cargo fmt --all --check` | Passed | +| Locked workspace/all-target/all-feature check | Passed | +| Strict workspace/all-target/all-feature Clippy (`-D warnings`, all/pedantic/nursery/cargo) | Passed on host Rust 1.98.1 and Linux ARM64 Rust 1.91 | +| `RUST_TEST_THREADS=1 cargo test --locked --workspace --all-features` | 2,509 passed: 1,251 library, 1,251 application, five CLI, one renderer and one doctest; none failed or ignored | +| Locked debug and optimized release builds of both binaries | Passed | +| Locked dependency policy | Passed; permitted duplicate dependency advisories remain informational | +| Offline update packaging/signing | Seven passed, including version/tag and architecture rejection | +| Admin/public JavaScript syntax, workflow YAML, browser harness checks, whitespace | Passed | +| Final corrected updater and backup/restore browser coverage | 75 passed, two explicit no-JavaScript automatic-reconnect skips, zero failures across 77 executions in two runs | + +The combined surrounding-admin/public baseline separately passed 235 cases with +87 explicit conditional skips and no failures (322 executions). It preceded the +stale-notice fix; final updater/state/browser results above validate that correction. +All seven maintained profiles launched and ran: Chromium, WebKit, Firefox, mobile +Firefox, mobile WebKit, Firefox without JavaScript and Chromium without JavaScript. + +Local Linux/Rust 1.91 strict lint passed on the final source with a read-only +repository mount. Earlier full Linux Docker validation passed both library and +application suites but repeatedly timed out in two CLI bootstrap database pools +after those suites. Isolated unchanged CLI tests passed five of five; the attempted +one-connection fixture override did not resolve the full-run failure and was removed. +The final CLI change only adds initialization stderr to a failure assertion. This +local full Docker run is not a pass or proof of native CI behavior. Required native +GitHub checks must validate the final candidate before protected integration. + +The first PR head's required Windows smoke exposed three native-only helpers +compiled without Unix guards; strict dead-code enforcement rejected the build. +The correction gates those helpers and their hash import with `cfg(unix)` without +relaxing lint or changing native behavior. Host strict lint passed, as did 46 updater/template tests and the exact native +renderer integration test. Every required native/browser check must run on the +new final PR head. Earlier-head passes do not satisfy that integration gate. + +The implementation task's earlier broad matrix was 1,290 passed, 913 explicit +skips and two initial failures; both passed its focused final recheck. Its original +full headless run is not described as an entirely green run. One unchanged +headless Firefox modified-click/new-tab limitation was reproduced outside RustChan +on this macOS host; headed Firefox passed the original assertion. Final independent +results above are reported separately. + +Native systemd/polkit privileges, actual migration binaries, process kills/reboot +and complete host recovery still require disposable Linux/systemd staging before +production use. Renderer fixtures, injected transaction/service failures and unit +syntax checks do not claim those live-host checks. Optional media-toolchain +coverage previously lacked host FFmpeg `libwebp` support and is not claimed here. + +The candidate uses the normal protected `main` pull-request route. Quality, Tests +and the Linux ARM64, macOS Apple Silicon and Windows x86_64 platform checks must +succeed on the final head before integration; protection is not bypassed. Browser +CI supplies the native Linux maintained-suite result separately from this local +macOS audit. + +Stable publication is held for the operator-provisioned signing secret. Repository +secret-name listing succeeded and was empty at audit time; no v1.6.0 tag or release +was created by this audit. A successful source integration is not a published +binary release. The existing signing gate cannot be skipped or converted to an +unsigned/prerelease substitute. + +The required stable release signing secret is `RUSTCHAN_UPDATE_SIGNING_KEY`. +Its private Ed25519 PEM is operator-provisioned, kept out of Git and copied offline; +the native trusted public key is independently authenticated. The exact setup is +in `docs/software-updates.md` under Release signing. No real signing key was +generated, read, uploaded or configured by this audit. Ephemeral synthetic signing +keys belong only to the existing offline packaging tests. + +## Browser CI findings and focused corrections + +The first complete native CI run on `d975b906c09ce5ef3114ee18b47ea2611393ead3` +passed Quality, Tests (2,509 cases), all three platform smoke jobs and Dependency +Audit. Chromium and Chromium no-JavaScript browser jobs passed. Firefox and WebKit +browser jobs failed and therefore that head was not integrated or released. + +Firefox's sole failure was a real native-audio test: intact PCM WAV data reached +the browser, but `OnMediaSinkAudioError` stopped playback without an output device. +An isolated official Playwright 1.63 Linux container reproduced the media-sink +failure and then passed real playback with a PulseAudio virtual sink. Both normal +and deep browser CI now configure that ephemeral output. Audio is not mocked or +muted; the playback and error-recovery assertions remain intact. + +WebKit exposed one application focus regression: resizing open mobile preferences +back to desktop focused a summary before WebKit had painted its visible layout. +The existing painted-layout focus restoration now covers that transition and +retains guards against focusing a reopened mobile modal. The original keyboard +regression passed on Linux WebKit; its reviewed after screenshot is +`output/playwright/public-polish/v160-focus-after-webkit-preferences-desktop-restored.png`. +The original GitHub failure screenshot/trace remains in the downloaded WebKit +artifact. This is a focused shared-script correction with no redesign. + +Two harness races were corrected: report submission now waits for the distinct +`reported=1` redirect instead of an already-matching thread URL, and accepted CSRF +submission waits for its thread redirect before returning to the board. The report +row, status, persistence, token renewal and tamper-rejection assertions remain. +The updater theme matrix is split by viewport into three bounded tests, preserving +all nine themes at 1280/390/320px, screenshots, focus and overflow assertions, +90-second budgets and zero retries. Linux WebKit passed all four originally failed +areas, including all three viewport/theme cases. + +Supplemental Linux engines connect through Playwright's loopback proxy to the +isolated local app. Full remote test results are not substituted for native CI: +`Download.path()` is unavailable through that connection, and an intentional +outage can report a SOCKS error instead of direct connection refusal. Those adapter +limitations remain explicit failures in the supplemental run; native GitHub jobs +must pass without weakening their assertions or changing their exclusions. + +Supplemental Linux results: 54 passed, two existing no-JavaScript skips and four +explicit remote-adapter failures (three local download-path calls and one outage +error classification). Separate Linux WebKit focus evidence passed 1/1. Strict +host lint, JavaScript syntax, all workflow YAML, whitespace and the optimized +both-binary rebuild passed after the focused corrections. The complete protected +GitHub native/browser jobs still gate the final PR head. + +The final focused macOS run after these browser corrections passed 101 cases with +four existing no-JavaScript enhancement skips and no failures (105 executions +across all seven profiles). It covers all eleven updater cases plus moderation, +preferences focus, signed public CSRF recovery and real native WAV playback. +Evidence is retained at `output/playwright/v160-ui-audit/mac-browser-fixes/`. +Offline packaging/signing remains 7/7; the optimized CLI identifies itself as +`rustchan-cli 1.6.0`. The supplemental Linux container was stopped after testing. + +## Final session-revocation fixture correction + +On `0bf98e3`, every required native job and Dependency Audit passed. Full native +Linux browser results were: Firefox 173 passed/148 conditional skips; WebKit 175 +passed/146 skips; Chromium no-JavaScript 164 passed/157 skips. Chromium had 282 +passes, 38 skips and one actual failure. Its three intentionally failing harness +contract cases behaved as declared and are not additional unexpected failures. + +Chromium's account-reset behavior assertions passed, but the target admin tab +polled Site Health after its session was intentionally revoked. The server +correctly returned 403; strict diagnostics rejected this unrelated background +request. The trace records reset POST 303 at 09:13:52.768Z and target health/jobs +403 at 09:13:57.252Z. No application authorization defect was found. + +The fixture now navigates the target to a public page without logging out before +reset. Added assertions prove the client retains its admin cookie and the server +still has exactly one target session before mutation. Existing reauthentication, +zero sessions after reset, protected-panel 403, recovery login and redaction +assertions remain intact. No error exclusions, timeouts, retries or skips were +added. Three independent repetitions across seven local profiles passed 21/21; +actual Linux Chromium repetitions passed 3/3 with strict diagnostics. Evidence is +in `output/playwright/v160-ui-audit/session-revocation-fix/` and +`linux-session-revocation-fix/`; the original failure trace/screenshot remains in +the downloaded Chromium artifact. Application code is unchanged by this fixture +correction, and full native/browser CI must pass again on the new final head. diff --git a/docs/software-updates.md b/docs/software-updates.md new file mode 100644 index 00000000..644cc976 --- /dev/null +++ b/docs/software-updates.md @@ -0,0 +1,186 @@ +# Software updates + +RustChan's **Software Updates** task is part of the existing administrator panel. +Only full administrator sessions can check releases, see availability or inspect +transaction/backup state. Public accounts and anonymous visitors have no access. +`/readyz` retains its existing status contract and adds `version`, the **running +package version**. It never reports release discovery or update transactions. + +## Boundary and supported deployments + +The web process sends only Status, Check, Ready and Install operations over +`/run/rustchan-updater/control.sock`. Install contains a one-use opaque approval +and an authenticated administrator ID; no request supplies a path, URL, command, +service or environment. Existing session-scoped CSRF and same-origin checks apply +to POST mutations. Installation additionally requires the current administrator +password and typing INSTALL. Failures share the existing login lockout. Approvals +expire 10 minutes after a compatible release check and are durably consumed +before an installation worker starts. + +The updater accepts only the configured web UID, verifies socket/file ownership, +serializes transactions using OS file locks and persists an atomic fsynced journal. +Configuration and trust keys are root-owned. The web identity cannot write the +updater executable, state, backup snapshots, version directories or active link. +Service actions are fixed argv calls to systemctl for **rustchan.service** start +and stop, with bounded execution; there is no shell or arbitrary service choice. + +Native installation supports Linux GNU x86_64 and aarch64 under the supplied +systemd setup. macOS, Windows, custom layouts and containers are deployment-managed +and expose Check only. Never enable native managed mode inside a container. +Ordinary manual/scheduled backup and restore controls remain available. Native installation holds RustChan’s existing maintenance gate through the preparation transaction, preventing overlap with backup/restore while ordinary posts continue. If an install reply is lost before approval consumption can be confirmed, maintenance remains paused; inspect updater status/logs before restarting the web service to clear an unconsumed request. + +## One-time Linux setup + +Use a disposable staging machine first. Keep an independent full backup before +converting an existing deployment. Do not perform conversion against a running +application. Commands below assume the default `/var/lib/rustchan` layout and an +existing configured instance. Override deployments require conversion to that +layout before native updates; database/media/runtime paths cannot point elsewhere. + +Create distinct locked service identities `rustchan` and `rustchan-updater` with +matching primary groups. Build both binaries with `cargo build --locked --release --bins` from the corresponding release source. Install the updater outside the version directories at +`/usr/local/libexec/rustchan-updater`, root-owned mode 0755. It is operator-managed; +software updates cannot replace their own trust/transaction engine. A release +requiring a newer updater is refused until the operator upgrades this executable. + +Prepare these paths and permissions: + +| Path | Owner | Mode / access | +| --- | --- | --- | +| `/etc/rustchan` | root:root | 0755, no group/other write | +| `/etc/rustchan/updater.toml` | root:root | 0644 | +| `/etc/rustchan/update-public-key.hex` | root:root | 0644 | +| `/opt/rustchan` and `versions` | rustchan-updater:rustchan-updater | 0755, no group/other write | +| `/opt/rustchan/versions//rustchan-cli` | rustchan-updater:rustchan-updater | 0755 | +| `/var/lib/rustchan-updater` and `backups` | rustchan-updater:rustchan-updater | 0700 | +| `/var/lib/rustchan` and its persistent files | rustchan:rustchan | existing RustChan private permissions | +| `/run/rustchan-updater` | rustchan-updater:rustchan | 0750 | +| `control.sock` | rustchan-updater:rustchan | 0660; peer UID must still match | + +Copy the running binary into `versions//rustchan-cli` and create +a relative `current -> versions/` symlink inside `/opt/rustchan`. +Preserve `chan.db`, `settings.toml`, `boards` and `runtime`; required roots must +exist and must not contain symlinks, hardlinked files or special files. +Ensure every ancestor of protected installation/state/key/config paths is owned +by root or the updater and cannot be written by the web account. Root-owned sticky +temporary parents are tolerated for isolated tests, not recommended deployment. + +Install the unit files and polkit rule from `deploy/systemd`, all root-owned. +Set `web_uid` in `/etc/rustchan/updater.toml` to `id -u rustchan`, confirm +`health_port` matches a working loopback HTTP `/readyz` listener (use the reverse proxy for public TLS), and create the private backups +directory before starting the updater. Install the trusted public key **through +an independently authenticated operator channel**. A downloaded key is not trusted +merely because it appears beside release assets. + +For managed updates, enabled ACME state must remain under the data directory’s `runtime/` tree; external mutable caches are rejected before stopping RustChan. + +The updater's systemd capability set allows reading owner-only application files +and restoring their original owner/mode. `ProtectSystem=strict`, `ProtectHome` +and explicit writable paths confine filesystem mutation to the managed application, +state and data roots. The web service remains unprivileged with NoNewPrivileges. +The polkit rule grants only start/stop of rustchan.service to the updater identity. +Review these privileges against the host's systemd/polkit policies. + +Validate with `systemd-analyze verify deploy/systemd/*.service`, then install units, +`systemctl daemon-reload`, and start the updater before the application. Managed +startup refuses to open/migrate the database or reconcile filesystem operations +until recovered updater admission matches the active running package version. +Check `journalctl -u rustchan-updater -u rustchan` and administrator readiness. + +## Verification and transactions + +Discovery uses the official `csd113/RustChan` GitHub Releases API, excludes draft +and prerelease versions, and orders stable tags semantically. Network deadlines, +response-size bounds and explicit HTTPS host/path redirect allowlists prevent +arbitrary fetches. Unverified or incompatible releases cannot enable installation. + +Each native artifact has an Ed25519 signature over the exact deterministic JSON +manifest. It binds GitHub release ID, application version, target triple, package +size/SHA-256, executable size/SHA-256, format, minimum updater and semantic database +migration range. The archive has exactly one regular `rustchan-cli` entry. Raw tar +validation refuses GNU/PAX metadata, traversal, absolute paths, links, duplicates, +unexpected entries and hidden trailing contents. Expanded bytes are bounded and +ELF class, endianness, executable type and machine architecture are checked. +Archives are staged separately, never unpacked over the live installation. + +Before outage, SQLite VACUUM INTO creates and verifies a live database/config +snapshot. After stopping the service, the updater refreshes that snapshot and +preserves **all boards and private runtime files**, including startup-mutable +media and Tor/TLS identity. Snapshot hashes, SQLite integrity/foreign keys, +configuration types and fixed file inventory are verified. Snapshots are private +and listed in the existing Backups task as verified pre-upgrade backups. They +are not interchangeable with a manually downloaded full-site backup, and the +web process cannot delete/restore them. Retention keeps the active rollback +snapshot and at least two recent snapshots; the immediately previous executable +is retained after success. + +Activation intent is journaled before atomic current-link replacement. The new +service performs its usual startup migration/reconciliation. The updater checks +bounded `/readyz` health and expected running version, then independently checks +the expected recorded schema and database integrity. Success is persisted only +after these checks pass. HTTP mutations fail closed during activation/recovery; +release discovery and download verification do not block ordinary writes. + +After an activation, migration, startup or readiness failure, the updater stops +the service and restores the previous database, configuration, boards/runtime and +version together. It never reverse-migrates. Restoration remains journaled until +the old service passes health checks. A crash during any restore stage causes the +same immutable snapshot to be replayed on the next updater startup. A preparation +interruption resumes the previous service; no update is marked successful merely +because its pointer changed. Failed recovery blocks startup and retains snapshots +for operator intervention. Never manually delete the journal to bypass recovery. + +The administrator sees persisted progress and final success/rollback/failure. +Enhanced clients tolerate outages and reconnect to the canonical Software Updates +task. Without JavaScript, the POST redirects explain the restart; reload the same +task after reconnecting to inspect the server-side result. + +## Release signing + +Generate an Ed25519 key using an operator OpenSSL installation: + +```sh +umask 077 +openssl genpkey -algorithm ED25519 -out rustchan-update-key.pem +openssl pkey -in rustchan-update-key.pem -pubout -outform DER -out rustchan-update-public.der +``` + +Store the private PEM as the repository secret `RUSTCHAN_UPDATE_SIGNING_KEY`. +Keep an offline recovery copy and never commit it. The trusted key file on the +installation contains the final 32 Ed25519 public-key bytes as 64 hex characters, +not the whole DER/PEM. The signing tool validates that the public DER has the +Ed25519 prefix and emits `.hex`/`.pem` public artifacts for operator distribution. +Key rotation is an explicit operator action, never an HTTP configuration change. + +`tools/update_package.py package` builds deterministic USTAR/gzip payloads for the +two supported native targets and validates ELF architecture and release identity. +It reads the package version from Cargo.toml, avoiding execution of cross-built +binaries. `rustchan-cli --update-info` prints build/schema/target protocol metadata +without creating a data directory. Review the declared minimum schema whenever +changing migrations; the initial supported upgrade baseline is 1.5.0. + +CI preserves existing platform ZIP releases and adds signed Linux update artifacts. +It creates a draft release to obtain its immutable GitHub release ID, verifies +both packages, signs/round-trip verifies both manifests, and publishes only after +signing succeeds. Signing failure leaves the release draft. Prereleases omit update +artifacts, are explicitly marked prerelease, and cannot advance stable latest. +Existing GHCR main/latest and version-tag policy remains unchanged. + +Run `python3 -m unittest discover -s tools -p test_update_package.py` for offline +packaging/signing regressions. Native systemd/polkit capabilities, reboot during +activation, actual migration binaries and service readiness still require a +disposable Linux/systemd staging deployment before production rollout. + +## Troubleshooting + +A failed check leaves the imageboard operational; retry later for GitHub outages +or rate limits. A release missing signatures/target assets or requiring an +unsupported schema/updater stays non-installable. Recheck after operator upgrades +or expired approvals. Resolve insufficient disk space before retrying; snapshots +include media and can be large. Never relax path ownership to fix an IPC failure. + +For failed rollback, keep RustChan stopped and inspect updater logs/journal and +immutable snapshots as the operator. Correct permissions, space, service/polkit or +snapshot integrity issues before restarting the updater. No web action can force +an unverified restore or clear failed recovery. Keep independent manual/offsite +backups; native rollback snapshots protect the local transaction, not disk loss. diff --git a/src/bin/rustchan-updater.rs b/src/bin/rustchan-updater.rs new file mode 100644 index 00000000..8f6b255e --- /dev/null +++ b/src/bin/rustchan-updater.rs @@ -0,0 +1,15 @@ +#![expect( + unused_crate_dependencies, + reason = "the updater links the shared library; Cargo also exposes dependencies used only by the web server" +)] +//! Restricted native Linux updater daemon. Configuration is operator-owned. + +/// Start the daemon with its fixed root-owned configuration. +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustls::crypto::ring::default_provider() + .install_default() + .map_err(|_provider| anyhow::anyhow!("TLS provider already configured"))?; + tracing_subscriber::fmt().with_env_filter("info").init(); + chan::updates::run(std::path::Path::new("/etc/rustchan/updater.toml")).await +} diff --git a/src/config.rs b/src/config.rs index ff6ed56f..20c69257 100644 --- a/src/config.rs +++ b/src/config.rs @@ -6,6 +6,34 @@ use std::io::{stderr, stdout, Write as _}; use std::path::Component; use std::path::{Path, PathBuf}; use std::sync::atomic::{AtomicU64, Ordering}; +#[cfg(unix)] +/// Validate the persisted settings syntax and known field types before update backup. +/// +/// # Errors +/// Returns an error for malformed TOML or invalid `RustChan` settings field types. +pub(crate) fn validate_update_settings(text: &str, data: &Path) -> anyhow::Result<()> { + let settings = parse_settings_file_str(text) + .map_err(|_error| anyhow::anyhow!("configuration backup is not valid RustChan settings"))?; + if let Some(tls) = settings.tls { + if tls.acme.enabled { + let cache = Path::new(&tls.acme.cache_dir); + let cache = if cache.is_absolute() { + cache.to_path_buf() + } else { + data.join(cache) + }; + anyhow::ensure!( + cache.starts_with(data.join("runtime")) + && cache + .components() + .all(|part| !matches!(part, Component::ParentDir)), + "managed updates require ACME state inside the protected runtime directory" + ); + } + } + Ok(()) +} + #[cfg(test)] use std::sync::Mutex; use std::sync::{LazyLock, OnceLock}; diff --git a/src/db/threads.rs b/src/db/threads.rs index 6386f90f..29915f21 100644 --- a/src/db/threads.rs +++ b/src/db/threads.rs @@ -49,6 +49,7 @@ pub struct PollInsert<'a> { /// 1 `t.board_id` 5 t.locked 9 `op.file_path` 13 op.id (`op_id`) /// 2 t.subject 6 t.sticky 10 `op.thumb_path` 14 t.archived /// 3 `t.created_at` 7 `t.reply_count` 11 op.name 15 `image_count` +/// 16 `last_post_at` fn map_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { Ok(Thread { id: row.get(0)?, @@ -56,6 +57,7 @@ fn map_thread(row: &rusqlite::Row<'_>) -> rusqlite::Result { subject: row.get(2)?, created_at: row.get(3)?, bumped_at: row.get(4)?, + last_post_at: row.get(16)?, locked: row.get::<_, i32>(5)? != 0, sticky: row.get::<_, i32>(6)? != 0, reply_count: row.get(7)?, @@ -130,10 +132,11 @@ const THREAD_SELECT: &str = " t.locked, t.sticky, t.reply_count, op.body, op.file_path, op.thumb_path, op.name, op.tripcode, op.id, t.archived, - COUNT(DISTINCT fp.id) AS image_count + COUNT(DISTINCT CASE WHEN fp.file_path IS NOT NULL THEN fp.id END) AS image_count, + COALESCE(MAX(fp.created_at), t.created_at) AS last_post_at FROM threads t JOIN posts op ON op.thread_id = t.id AND op.is_op = 1 - LEFT JOIN posts fp ON fp.thread_id = t.id AND fp.file_path IS NOT NULL"; + LEFT JOIN posts fp ON fp.thread_id = t.id"; /// Get paginated threads for a board with OP preview data. /// Sticky threads float to the top, then sorted by most recent bump. @@ -1041,6 +1044,75 @@ mod tests { } } + #[test] + fn listing_last_post_includes_non_bumping_text_and_audio_without_counting_them_as_images( + ) -> Result<()> { + let conn = test_conn()?; + let board_id = create_board(&conn, "read", "Reading", "", false)?; + let thread_id = create_plain_thread(&conn, board_id, "opening post only")?; + conn.execute( + "UPDATE threads SET created_at = 100, bumped_at = 100 WHERE id = ?1", + params![thread_id], + )?; + conn.execute( + "UPDATE posts SET created_at = 100 WHERE thread_id = ?1", + params![thread_id], + )?; + let thread = super::get_thread(&conn, thread_id)?.context("opening thread should exist")?; + anyhow::ensure!( + thread.last_post_at == 100 && thread.image_count == 0, + "a thread without replies uses the opening-post time and has no images" + ); + + let mut image = plain_reply(board_id, thread_id); + image.file_path = Some("read/image.png".to_owned()); + let image_id = create_reply_with_thread_update(&conn, &image, "", false, None)?; + let mut audio = plain_reply(board_id, thread_id); + audio.audio_file_path = Some("read/audio.wav".to_owned()); + let audio_id = create_reply_with_thread_update(&conn, &audio, "", false, None)?; + let text_id = create_reply_with_thread_update( + &conn, + &plain_reply(board_id, thread_id), + "", + false, + None, + )?; + for (post_id, timestamp) in [(image_id, 200), (audio_id, 300), (text_id, 400)] { + conn.execute( + "UPDATE posts SET created_at = ?1 WHERE id = ?2", + params![timestamp, post_id], + )?; + } + let thread = + super::get_thread(&conn, thread_id)?.context("thread should exist after replies")?; + anyhow::ensure!( + thread.last_post_at == 400 && thread.bumped_at == 100 && thread.image_count == 1, + "sage text replies update last-post time while bump time and image counts remain correct" + ); + let active = super::get_threads_for_board(&conn, board_id, 20, 0)?; + anyhow::ensure!(active.len() == 1, "the active listing contains one thread"); + let active = active.first().context("active thread should exist")?; + anyhow::ensure!( + active.last_post_at == 400 && active.image_count == 1, + "catalog/index listing must retain the same post and image aggregates" + ); + conn.execute( + "UPDATE threads SET archived = 1 WHERE id = ?1", + params![thread_id], + )?; + let archived = super::get_archived_threads_for_board(&conn, board_id, 20, 0)?; + anyhow::ensure!( + archived.len() == 1, + "the archive listing contains one thread" + ); + let archived = archived.first().context("archived thread should exist")?; + anyhow::ensure!( + archived.last_post_at == 400 && archived.image_count == 1, + "archive listing must retain the same post and image aggregates" + ); + Ok(()) + } + fn pending_upload_op(id: &str) -> crate::pending_fs::PendingFsOpInsert { crate::pending_fs::PendingFsOpInsert { id: id.to_owned(), diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index a64c1dd1..7b5acee8 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -15,6 +15,8 @@ mod accounts; pub(in crate::server) mod auth; +/// Administrator-only software update handlers. +pub(in crate::server) mod updates; pub(in crate::server) use accounts::*; pub(in crate::server) use auth::*; @@ -2130,6 +2132,8 @@ pub(in crate::server) async fn admin_panel( } else { None }; + updates::authorize(&state, &jar).await?; + let update_status = updates::snapshot().await; let auto_full_backup_settings = state.auto_full_backup_settings.snapshot(); let html = tokio::task::spawn_blocking({ let pool = state.db.clone(); @@ -2162,6 +2166,9 @@ pub(in crate::server) async fn admin_panel( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + let html = html.replace("", &crate::templates::admin::render_software_updates(&update_status, &csrf, crate::updates::managed(), crate::updates::container_managed())) + .replace("", &crate::templates::admin::render_update_backups(&update_status)) + .replace("", if update_status.available_release().is_some() { r#" Update available"# } else { "" }); Ok((jar, Html(html))) } diff --git a/src/handlers/admin/updates.rs b/src/handlers/admin/updates.rs new file mode 100644 index 00000000..790922e6 --- /dev/null +++ b/src/handlers/admin/updates.rs @@ -0,0 +1,254 @@ +//! Administrator-only software discovery and reauthenticated installation requests. + +use super::{require_admin_post_origin_and_csrf, require_admin_session_sid, SESSION_COOKIE}; +use crate::{ + db, + error::{AppError, Result}, + middleware::AppState, + updates::{self, Request, Status}, +}; +use axum::{ + extract::{ConnectInfo, Form, State}, + http::HeaderMap, + response::{IntoResponse as _, Response}, + Json, +}; +use axum_extra::extract::cookie::CookieJar; +use std::io::Read as _; + +/// Closed check request; installation credentials cannot become paths or commands. +#[derive(Debug, serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::server) struct CheckForm { + /// Session-scoped form CSRF token. + #[serde(rename = "_csrf")] + csrf: Option, +} + +/// One-use approval plus the current administrator's password. +#[derive(serde::Deserialize)] +#[serde(deny_unknown_fields)] +pub(in crate::server) struct InstallForm { + /// Session-scoped CSRF token. + #[serde(rename = "_csrf")] + csrf: Option, + /// Opaque updater-issued approval, consumed durably before installation starts. + approval: String, + /// Never serialized, returned or logged. + current_password: String, + /// Explicit acknowledgment of the restart. + confirmation: String, +} + +/// Authorize before reading any discovery metadata or contacting the updater. +pub(super) async fn authorize(state: &AppState, jar: &CookieJar) -> Result { + let session = jar + .get(SESSION_COOKIE) + .map(|cookie| cookie.value().to_owned()); + let pool = state.db.clone(); + tokio::task::spawn_blocking(move || { + let conn = pool.get()?; + require_admin_session_sid(&conn, session.as_deref()) + }) + .await + .map_err(|error| AppError::Internal(error.into()))? +} + +/// Fetch persisted update state only after the caller has authorized the administrator. +pub(super) async fn snapshot() -> Status { + if updates::managed() { + return updates::request(&Request::Status).await.map_or_else(|_| Status { + installed: updates::VERSION.to_owned(), + message: "Updater unavailable. Check the native updater service; installation is disabled.".to_owned(), + ..Status::default() + }, |reply| reply.status); + } + tokio::task::spawn_blocking(|| { + let path = crate::config::runtime_dir().join("software-updates.json"); + let status = std::fs::File::open(path) + .ok() + .and_then(|file| { + let mut bytes = Vec::new(); + file.take(512 * 1024 + 1).read_to_end(&mut bytes).ok()?; + (bytes.len() <= 512 * 1024).then_some(bytes) + }) + .and_then(|bytes| serde_json::from_slice::(&bytes).ok()); + status.unwrap_or_else(|| Status { + installed: updates::VERSION.to_owned(), + ..Status::default() + }) + }) + .await + .unwrap_or_default() +} + +/// Return status with admin-only cache policy inherited from the admin route group. +pub(in crate::server) async fn status( + State(state): State, + jar: CookieJar, +) -> Result { + authorize(&state, &jar).await?; + Ok(Json(snapshot().await).into_response()) +} + +/// Perform bounded official release discovery; no service state changes. +pub(in crate::server) async fn check( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + ConnectInfo(peer): ConnectInfo, + Form(form): Form, +) -> Result { + require_admin_post_origin_and_csrf(&jar, &headers, Some(peer), form.csrf.as_deref())?; + authorize(&state, &jar).await?; + let _guard = state.maintenance_gate.try_begin("Software update check")?; + let outcome = if updates::managed() { + updates::request(&Request::Check).await.and_then(|reply| { + anyhow::ensure!(reply.error.is_none(), "updater check failed"); + Ok(()) + }) + } else { + tokio::task::spawn_blocking(|| -> anyhow::Result<()> { + let status = Status { + installed: updates::VERSION.to_owned(), + discovery: Some(updates::discover(updates::VERSION, None)), + checked_at: Some(chrono::Utc::now().to_rfc3339()), + message: "Release check completed. Installation is deployment-managed.".to_owned(), + ..Status::default() + }; + crate::config::write_private_file( + &crate::config::runtime_dir().join("software-updates.json"), + &serde_json::to_vec(&status)?, + )?; + Ok(()) + }) + .await + .map_err(|error| AppError::Internal(error.into()))? + }; + Ok(if outcome.is_ok() { + super::admin_panel_redirect_anchor("Release check completed.", "software-updates") + } else { + super::admin_panel_error_redirect_anchor( + "Release check could not complete. Retry later or check updater logs.", + "software-updates", + ) + } + .into_response()) +} + +/// Reauthenticate using existing login lockout before sending the closed Install operation. +pub(in crate::server) async fn install( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + ConnectInfo(peer): ConnectInfo, + crate::middleware::ClientIp(client): crate::middleware::ClientIp, + Form(form): Form, +) -> Result { + require_admin_post_origin_and_csrf(&jar, &headers, Some(peer), form.csrf.as_deref())?; + let session = jar + .get(SESSION_COOKIE) + .map(|cookie| cookie.value().to_owned()); + let key = super::auth::login_ip_key(&client); + if super::auth::is_login_locked(&key) { + return Err(AppError::Forbidden( + "Reauthentication locked. Retry after the failed-login window.".to_owned(), + )); + } + let maintenance_gate = state.maintenance_gate.clone(); + let approval = form.approval; + let password = form.current_password; + let confirmation = form.confirmation; + let approval_valid = uuid::Uuid::parse_str(&approval).is_ok(); + let administrator = tokio::task::spawn_blocking(move || -> Result { + let conn = state.db.get()?; + let actor = require_admin_session_sid(&conn, session.as_deref())?; + let name = db::get_admin_name_by_id(&conn, actor)? + .ok_or_else(|| AppError::Forbidden("Administrator missing.".to_owned()))?; + let user = db::get_admin_by_username(&conn, &name)? + .ok_or_else(|| AppError::Forbidden("Administrator missing.".to_owned()))?; + if password.len() > 1024 + || !crate::utils::crypto::verify_password(&password, &user.password_hash)? + { + super::auth::record_login_fail(&key); + return Err(AppError::Forbidden( + "Current password did not verify. No update started.".to_owned(), + )); + } + if !updates::managed() || confirmation != "INSTALL" || !approval_valid { + return Err(AppError::BadRequest( + "Native installation is unavailable or confirmation is invalid.".to_owned(), + )); + } + Ok(actor) + }) + .await + .map_err(|error| AppError::Internal(error.into()))??; + let guard = maintenance_gate.try_begin("Software update installation")?; + let outcome = updates::request(&Request::Install { + approval: approval.clone(), + administrator, + }) + .await; + let acknowledged = outcome.is_ok(); + tokio::spawn(async move { + let _guard = guard; + loop { + if updates::request(&Request::Status) + .await + .is_ok_and(|reply| maintenance_finished(&reply.status, &approval, acknowledged)) + { + break; + } + tokio::time::sleep(std::time::Duration::from_secs(2)).await; + } + }); + let reply = outcome.map_err(|_error| { + AppError::BadRequest( + "Updater unavailable; reload Software Updates to check the persisted result." + .to_owned(), + ) + })?; + if reply.error.is_some() { + return Err(AppError::BadRequest( + "Update could not start. Check releases again, then retry after resolving the reported issue.".to_owned(), + )); + } + Ok(super::admin_panel_redirect_anchor("Update accepted. RustChan will restart; reload this page to see the verified final result.", "software-updates").into_response()) +} + +/// Keep existing backup/restore maintenance paused until a safe terminal result is known. +/// A lost install reply cannot prove rejection while the same approval remains outstanding. +fn maintenance_finished(status: &Status, approval: &str, acknowledged: bool) -> bool { + !status.phase.active() && (acknowledged || status.approval.as_deref() != Some(approval)) +} + +#[cfg(test)] +/// Installation maintenance admission regression tests. +mod tests { + use super::*; + + /// Lost replies and active work cannot prematurely release backup/restore serialization. + #[test] + fn maintenance_waits_for_consumed_approval_and_terminal_state() { + let mut status = Status { + approval: Some("pending".to_owned()), + ..Status::default() + }; + assert!( + !maintenance_finished(&status, "pending", false), + "lost reply with pending approval must hold maintenance" + ); + status.approval = None; + status.phase = updates::Phase::Downloading; + assert!( + !maintenance_finished(&status, "pending", true), + "accepted download must serialize maintenance without blocking posts" + ); + status.phase = updates::Phase::RolledBack; + assert!( + maintenance_finished(&status, "pending", false), + "persisted complete rollback may release maintenance even after a lost reply" + ); + } +} diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index f2a6f445..878fe4a4 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -3,7 +3,7 @@ use super::{ handled_post_error_status, identity_key, index_reply_previews, index_threads_per_page, is_xml_http_request, make_scoped_csrf_form_token, parse_post_multipart, posting, remember_owned_post_until_with_secure, render, self_action_window_secs, - should_set_public_secure_cookie, templates, unlock_redirect_url, user_preferences_from_jar, + should_set_public_secure_cookie, unlock_redirect_url, user_preferences_from_jar, xhr_error_response, xhr_post_error_response, xhr_redirect_response, AppError, AppState, BoardAccessDecision, BoardAccessRequirement, CookieJar, HashMap, HeaderMap, Html, Multipart, Path, PostFormData, Redirect, Response, Result, SecureCookieContext, State, @@ -54,7 +54,8 @@ pub(in crate::server) async fn create_thread( }; let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_owned()); - let form = tokio::time::timeout( + let mut parse_draft = crate::templates::forms::PostFormState::default(); + let parsed_form = tokio::time::timeout( crate::handlers::PUBLIC_UPLOAD_TIMEOUT, parse_post_multipart( multipart, @@ -63,23 +64,41 @@ pub(in crate::server) async fn create_thread( access_context.board.max_video_size_bytes(), access_context.board.max_audio_size_bytes(), access_context.board.max_pdf_size_bytes(), - &state.media_upload_gate, + crate::handlers::PostMultipartContext { + media_upload_gate: &state.media_upload_gate, + draft: &mut parse_draft, + }, ), ) .await - .map_err(|_error| AppError::BadRequest("Upload timed out. Please try again.".into()))??; + .map_err(|_error| AppError::BadRequest("Upload timed out. Please try again.".into())) + .and_then(std::convert::identity); + let form = match parsed_form { + Ok(form) => form, + Err(error) => { + if xhr_request { + return xhr_post_error_response(error); + } + return Ok(crate::handlers::native_post_error_response( + error, + Some(&parse_draft), + )); + } + }; + + let post_form_state = parse_draft; if !form.csrf_verified { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); + let error = AppError::Forbidden("CSRF token mismatch. Reload the posting page for a fresh form after copying your draft.".into()); + if xhr_request { + return xhr_post_error_response(error); + } + return Ok(crate::handlers::native_post_error_response( + error, + Some(&post_form_state), + )); } - let post_form_state = templates::forms::PostFormState { - name: form.name.clone(), - subject: form.subject.clone(), - body: form.body.clone(), - sage: form.sage, - }; - // Also extract csrf_token before spawn_blocking so the ban page appeal form works. let ban_csrf_token = csrf_cookie.clone().unwrap_or_default(); @@ -167,7 +186,10 @@ pub(in crate::server) async fn create_thread( if xhr_request { return xhr_post_error_response(error); } - return Err(error); + return Ok(crate::handlers::native_post_error_response( + error, + Some(&post_form_state), + )); } }; if xhr_request { diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index 84831424..2f8ad974 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -24,7 +24,7 @@ use axum::{ body::Body, extract::{Multipart, Request}, middleware::Next, - response::Response, + response::{IntoResponse as _, Response}, }; use futures::StreamExt as _; use std::collections::HashSet; @@ -459,8 +459,22 @@ fn error_chain_contains(error: &(dyn std::error::Error + 'static), needle: &str) #[derive(Default)] struct PublicMultipartBudget { + /// Count shared by normal parsing and rejected-upload recovery. fields_seen: usize, + /// Aggregate field-data bytes observed across the request. bytes_seen: usize, + /// Additional allowance activated only after a board upload limit rejects a file. + rejected_upload: Option, +} + +/// One shared allowance for rejected field data and trailing draft controls. +struct RejectedUploadRecovery { + /// Rejected excess and trailing controls consumed within the shared allowance. + bytes_seen: usize, + /// Single deadline for the rejected field and every trailing control. + deadline: tokio::time::Instant, + /// Whether the rejected field ended before the allowance was exhausted. + field_complete: bool, } impl PublicMultipartBudget { @@ -481,6 +495,16 @@ impl PublicMultipartBudget { "Multipart upload is too large.".into(), )); } + if let Some(recovery) = &mut self.rejected_upload { + recovery.bytes_seen = recovery.bytes_seen.saturating_add(len); + if recovery.bytes_seen > REJECTED_UPLOAD_DISCARD_MAX_BYTES + || tokio::time::Instant::now() >= recovery.deadline + { + return Err(AppError::UploadTooLarge( + "Rejected upload recovery limit reached.".into(), + )); + } + } Ok(()) } } @@ -562,13 +586,59 @@ async fn discard_unknown_public_multipart_field( // Streaming multipart size limit // -// Upload fields stream directly to disk and abort with HTTP 413 as soon as the -// running total exceeds the configured board limit. +// Upload fields stream directly to disk and stop staging at the configured +// board limit. A small, time-bounded discard can then deliver the original 413 +// without retaining or processing excess bytes. // // Text fields (CSRF token, post body, …) use the same chunked parser with a // small fixed cap, so disabling Axum's route-level body limit for upload routes // does not leave text fields unbounded. +/// Maximum additional upload bytes discarded after a board-size rejection. +const REJECTED_UPLOAD_DISCARD_MAX_BYTES: usize = 8 * 1024 * 1024; +/// Maximum time spent discarding a rejected upload before closing its request. +const REJECTED_UPLOAD_DISCARD_TIMEOUT: Duration = Duration::from_secs(1); + +/// Discards a small finite rejected field so browsers can receive its original error. +/// Excess bytes never reach disk, and every read retains the aggregate upload budget. +async fn discard_rejected_upload_field( + field: &mut axum::extract::multipart::Field<'_>, + budget: &mut PublicMultipartBudget, + initial_excess_bytes: usize, +) -> bool { + let deadline = tokio::time::Instant::now() + REJECTED_UPLOAD_DISCARD_TIMEOUT; + budget.rejected_upload = Some(RejectedUploadRecovery { + bytes_seen: initial_excess_bytes, + deadline, + field_complete: false, + }); + if initial_excess_bytes > REJECTED_UPLOAD_DISCARD_MAX_BYTES { + return false; + } + let discard = async { + loop { + // Check ready streams too; the outer timer also bounds stalled reads. + if tokio::time::Instant::now() >= deadline { + return false; + } + let Ok(chunk) = field.chunk().await else { + return false; + }; + let Some(chunk) = chunk else { + return true; + }; + if budget.note_chunk(chunk.len()).is_err() { + return false; + } + } + }; + let complete = matches!(tokio::time::timeout_at(deadline, discard).await, Ok(true)); + if let Some(recovery) = &mut budget.rejected_upload { + recovery.field_complete = complete; + } + complete +} + async fn stream_field_to_temp_file( mut field: axum::extract::multipart::Field<'_>, max_bytes: usize, @@ -612,6 +682,13 @@ async fn stream_field_to_temp_file( limit_bytes = max_bytes, "multipart upload field exceeded board limit" ); + // Keep the media permit until this bounded discard completes; reject + // regardless of the outcome and never stage or inspect excess bytes. + let excess = size_bytes + .saturating_add(chunk.len()) + .saturating_sub(max_bytes); + let _discard_completed = + discard_rejected_upload_field(&mut field, budget, excess).await; return Err(AppError::UploadTooLarge(format!( "File too large. Maximum upload size is {}.", format_upload_limit(max_bytes) @@ -722,6 +799,168 @@ pub(crate) struct PostFormData { pub captcha_answer: String, } +/// Keeps native posting failures actionable without changing their status or security checks. +pub(crate) fn native_post_error_response( + error: AppError, + prefill: Option<&crate::templates::forms::PostFormState>, +) -> Response { + let mut response = error.into_response(); + let Some(crate::error::ErrorPage::Message(message)) = + response.extensions().get::() + else { + // Ban pages retain their dedicated explanation and appeal form. + return response; + }; + let body = crate::templates::forms::post_error_recovery_body(message, prefill); + let html = crate::templates::base_layout( + "Posting error", + None, + &body, + "", + &crate::templates::live_boards(), + None, + None, + false, + "/", + ); + response + .extensions_mut() + .insert(crate::error::ErrorPage::Content { + title: "Posting error".into(), + body, + }); + *response.body_mut() = Body::from(html); + response +} + +/// Request resources and a bounded draft retained even when parsing fails. +pub(crate) struct PostMultipartContext<'a> { + /// Existing concurrency gate held while upload bytes are staged or discarded. + pub media_upload_gate: &'a crate::middleware::MediaUploadGate, + /// Escaped at rendering; records only bounded recoverable user controls. + pub draft: &'a mut crate::templates::forms::PostFormState, +} + +/// Records only known bounded user controls; never tokens, CAPTCHA answers or uploads. +fn capture_post_draft( + name: &str, + value: &str, + draft: &mut crate::templates::forms::PostFormState, +) -> Result<()> { + match name { + "name" => value.clone_into(&mut draft.name), + "subject" => value.clone_into(&mut draft.subject), + "body" => value.clone_into(&mut draft.body), + "sage" => { + draft.sage = value == "1" + || value.eq_ignore_ascii_case("on") + || value.eq_ignore_ascii_case("true"); + } + "poll_question" if value.chars().count() <= 500 => { + value.clone_into(&mut draft.poll.get_or_insert_default().question); + } + "poll_option" => { + let value = value.trim(); + if !value.is_empty() { + let poll = draft.poll.get_or_insert_default(); + if poll.options.len() >= 20 || value.chars().count() > 200 { + return Err(AppError::BadRequest( + "Poll recovery control exceeds its limit.".into(), + )); + } + poll.options.push(value.to_owned()); + } + } + "poll_duration_value" => { + value.clone_into(&mut draft.poll.get_or_insert_default().duration_value); + } + "poll_duration_unit" => { + value.clone_into(&mut draft.poll.get_or_insert_default().duration_unit); + } + "poll_question" => { + return Err(AppError::BadRequest( + "Poll recovery question exceeds its limit.".into(), + )) + } + _ => {} + } + Ok(()) +} + +/// Reads trailing controls only after a finite rejected field; shares its cap and deadline. +async fn recover_rejected_post_tail( + multipart: &mut Multipart, + budget: &mut PublicMultipartBudget, + draft: &mut crate::templates::forms::PostFormState, +) { + let Some(recovery) = budget + .rejected_upload + .as_ref() + .filter(|state| state.field_complete) + else { + return; + }; + let deadline = recovery.deadline; + let recover = async { + loop { + if tokio::time::Instant::now() >= deadline { + return Err(AppError::BadRequest("Draft recovery timed out.".into())); + } + let next_field = multipart + .next_field() + .await + .map_err(|error| multipart_read_error("draft recovery", &error))?; + let Some(mut field) = next_field else { + return Ok(()); + }; + budget.note_field()?; + let name = field.name().unwrap_or_default().to_owned(); + match name.as_str() { + "name" + | "subject" + | "body" + | "sage" + | "poll_question" + | "poll_option" + | "poll_duration_value" + | "poll_duration_unit" => { + let value = read_text_field(field, budget).await?; + capture_post_draft(&name, &value, draft)?; + } + "file" | "audio_file" | "image_file" => { + draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + loop { + let next_chunk = field.chunk().await.map_err(|error| { + multipart_read_error("rejected upload tail", &error) + })?; + let Some(chunk) = next_chunk else { + break; + }; + budget.note_chunk(chunk.len())?; + } + } + _ => discard_unknown_public_multipart_field(field, budget).await?, + } + } + }; + if matches!(tokio::time::timeout_at(deadline, recover).await, Ok(Ok(()))) { + draft.incomplete = false; + } +} + +/// Retains the original upload error after bounded best-effort draft recovery. +async fn recover_upload_parse_error( + result: Result, + multipart: &mut Multipart, + budget: &mut PublicMultipartBudget, + draft: &mut crate::templates::forms::PostFormState, +) -> Result { + if result.is_err() { + recover_rejected_post_tail(multipart, budget, draft).await; + } + result +} + /// Drain all fields from a multipart form into [`PostFormData`]. /// `csrf_cookie` is the value from the browser cookie for CSRF verification. #[expect( @@ -739,8 +978,17 @@ pub(crate) async fn parse_post_multipart( max_video_size: usize, max_audio_size: usize, max_pdf_size: usize, - media_upload_gate: &crate::middleware::MediaUploadGate, + context: PostMultipartContext<'_>, ) -> Result { + let PostMultipartContext { + media_upload_gate, + draft, + } = context; + draft.incomplete = true; + draft.poll = Some(crate::templates::forms::PollFormState { + duration_unit: "hours".into(), + ..Default::default() + }); tracing::info!(target: LOG_TARGET, max_image_bytes = max_image_size, max_video_bytes = max_video_size, @@ -760,7 +1008,7 @@ pub(crate) async fn parse_post_multipart( let mut image_file: Option<(TempUpload, String)> = None; let mut poll_question = String::new(); let mut poll_options: Vec = Vec::new(); - let mut poll_duration_value: Option = None; + let mut poll_duration_value = String::new(); let mut poll_duration_unit = String::from("hours"); let mut sage = false; let mut captcha_id = String::new(); @@ -788,13 +1036,23 @@ pub(crate) async fn parse_post_multipart( Some("submission_token") => { submission_token = read_text_field(field, &mut budget).await?; } - Some("name") => name = read_text_field(field, &mut budget).await?, - Some("subject") => subject = read_text_field(field, &mut budget).await?, - Some("body") => body = read_text_field(field, &mut budget).await?, + Some("name") => { + name = read_text_field(field, &mut budget).await?; + capture_post_draft("name", &name, draft)?; + } + Some("subject") => { + subject = read_text_field(field, &mut budget).await?; + capture_post_draft("subject", &subject, draft)?; + } + Some("body") => { + body = read_text_field(field, &mut budget).await?; + capture_post_draft("body", &body, draft)?; + } Some("deletion_token") => deletion_token = read_text_field(field, &mut budget).await?, Some("sage") => { let v = read_text_field(field, &mut budget).await?; sage = v == "1" || v.eq_ignore_ascii_case("on") || v.eq_ignore_ascii_case("true"); + capture_post_draft("sage", &v, draft)?; } Some("captcha_id") => captcha_id = read_text_field(field, &mut budget).await?, Some("captcha_answer") => { @@ -807,6 +1065,7 @@ pub(crate) async fn parse_post_multipart( "Poll question must be 500 characters or fewer.".into(), )); } + capture_post_draft("poll_question", &v, draft)?; poll_question = v; } Some("poll_option") => { @@ -823,15 +1082,17 @@ pub(crate) async fn parse_post_multipart( "Each poll option must be 200 characters or fewer.".into(), )); } + capture_post_draft("poll_option", &trimmed, draft)?; poll_options.push(trimmed); } } Some("poll_duration_value") => { - let v = read_text_field(field, &mut budget).await?; - poll_duration_value = v.trim().parse::().ok(); + poll_duration_value = read_text_field(field, &mut budget).await?; + capture_post_draft("poll_duration_value", &poll_duration_value, draft)?; } Some("poll_duration_unit") => { poll_duration_unit = read_text_field(field, &mut budget).await?; + capture_post_draft("poll_duration_unit", &poll_duration_unit, draft)?; } Some("file") => { if !seen_upload_slots.insert("file") { @@ -839,7 +1100,8 @@ pub(crate) async fn parse_post_multipart( "Duplicate upload field 'file'.".into(), )); } - file = read_upload_field( + draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + let upload_result = read_upload_field( field, max_image_size .max(max_video_size) @@ -851,7 +1113,11 @@ pub(crate) async fn parse_post_multipart( media_upload_gate, &mut media_upload_guard, ) - .await?; + .await; + draft.had_attachments |= upload_result.as_ref().is_ok_and(Option::is_some); + file = + recover_upload_parse_error(upload_result, &mut multipart, &mut budget, draft) + .await?; } Some("audio_file") => { if !seen_upload_slots.insert("audio_file") { @@ -859,7 +1125,8 @@ pub(crate) async fn parse_post_multipart( "Duplicate upload field 'audio_file'.".into(), )); } - audio_file = read_upload_field( + draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + let upload_result = read_upload_field( field, max_audio_size, "audio", @@ -868,7 +1135,11 @@ pub(crate) async fn parse_post_multipart( media_upload_gate, &mut media_upload_guard, ) - .await?; + .await; + draft.had_attachments |= upload_result.as_ref().is_ok_and(Option::is_some); + audio_file = + recover_upload_parse_error(upload_result, &mut multipart, &mut budget, draft) + .await?; } Some("image_file") => { if !seen_upload_slots.insert("image_file") { @@ -876,7 +1147,8 @@ pub(crate) async fn parse_post_multipart( "Duplicate upload field 'image_file'.".into(), )); } - image_file = read_upload_field( + draft.had_attachments |= field.file_name().is_some_and(|name| !name.is_empty()); + let upload_result = read_upload_field( field, max_image_size, "image", @@ -885,7 +1157,11 @@ pub(crate) async fn parse_post_multipart( media_upload_gate, &mut media_upload_guard, ) - .await?; + .await; + draft.had_attachments |= upload_result.as_ref().is_ok_and(Option::is_some); + image_file = + recover_upload_parse_error(upload_result, &mut multipart, &mut budget, draft) + .await?; } _ => { discard_unknown_public_multipart_field(field, &mut budget).await?; @@ -893,13 +1169,15 @@ pub(crate) async fn parse_post_multipart( } } + draft.incomplete = false; + // Convert duration value + unit → seconds (saturating to prevent overflow). // The unit is validated against an explicit allow-list (case-insensitive) so // that a tampered form field does not silently multiply by an arbitrary factor. let poll_duration_secs = if poll_question.trim().is_empty() { None } else { - match poll_duration_value { + match poll_duration_value.trim().parse::().ok() { None => None, Some(v) => { let unit = poll_duration_unit.trim().to_ascii_lowercase(); @@ -1458,7 +1736,7 @@ trailer << /Root 1 0 R >> boundary: &str, body: Vec, ) -> anyhow::Result { - let request = Request::builder() + let mut request = Request::builder() .method("POST") .uri("/parse") .header( @@ -1467,11 +1745,260 @@ trailer << /Root 1 0 R >> ) .body(Body::from(body)) .context("build multipart extraction request")?; + // Match public posting routes: the parser enforces its own board and aggregate caps. + axum::extract::DefaultBodyLimit::disable().apply(&mut request); axum::extract::Multipart::from_request(request, &()) .await .map_err(|rejection| anyhow::anyhow!(rejection.to_string())) } + #[tokio::test] + async fn rejected_upload_discard_finishes_without_staging_bytes() -> anyhow::Result<()> { + let (boundary, body) = + multipart_body_with_files(&[], &[("file", "oversized.png", &[1; 2048], "image/png")]); + let mut multipart = multipart_from_bytes(&boundary, body).await?; + let mut field = multipart + .next_field() + .await? + .context("missing upload field")?; + let mut budget = super::PublicMultipartBudget::default(); + ensure!(super::discard_rejected_upload_field(&mut field, &mut budget, 0).await); + ensure!(budget.bytes_seen == 2048); + ensure!(field.chunk().await?.is_none()); + Ok(()) + } + + #[tokio::test] + async fn rejected_upload_discard_stops_at_excess_and_aggregate_limits() -> anyhow::Result<()> { + let (boundary, body) = + multipart_body_with_files(&[], &[("file", "oversized.png", &[1; 2048], "image/png")]); + let mut multipart = multipart_from_bytes(&boundary, body).await?; + let mut field = multipart + .next_field() + .await? + .context("missing upload field")?; + let mut budget = super::PublicMultipartBudget::default(); + ensure!( + !super::discard_rejected_upload_field( + &mut field, + &mut budget, + super::REJECTED_UPLOAD_DISCARD_MAX_BYTES + ) + .await + ); + ensure!(budget.bytes_seen == 2048); + + let (boundary, body) = + multipart_body_with_files(&[], &[("file", "oversized.png", &[1; 2048], "image/png")]); + let mut multipart = multipart_from_bytes(&boundary, body).await?; + let mut field = multipart + .next_field() + .await? + .context("missing upload field")?; + let mut budget = super::PublicMultipartBudget { + fields_seen: 0, + bytes_seen: super::PUBLIC_MULTIPART_AGGREGATE_MAX_BYTES, + ..super::PublicMultipartBudget::default() + }; + ensure!(!super::discard_rejected_upload_field(&mut field, &mut budget, 0).await); + Ok(()) + } + + #[tokio::test] + async fn rejected_upload_discard_times_out_on_a_stalled_field() -> anyhow::Result<()> { + use futures::StreamExt as _; + let prefix = axum::body::Bytes::from_static(b"--stalled\r\nContent-Disposition: form-data; name=\"file\"; filename=\"stalled.png\"\r\n\r\npartial bytes"); + let stream = futures::stream::once(async { Ok::<_, std::io::Error>(prefix) }) + .chain(futures::stream::pending()); + let request = Request::builder() + .header( + header::CONTENT_TYPE, + "multipart/form-data; boundary=stalled", + ) + .body(Body::from_stream(stream))?; + let mut multipart = axum::extract::Multipart::from_request(request, &()).await?; + let mut field = multipart + .next_field() + .await? + .context("missing stalled upload field")?; + let mut budget = super::PublicMultipartBudget::default(); + let completed = tokio::time::timeout( + std::time::Duration::from_secs(5), + super::discard_rejected_upload_field(&mut field, &mut budget, 0), + ) + .await?; + ensure!(!completed); + Ok(()) + } + + /// Constructs the native form order: user text, rejected upload, then poll controls. + fn rejected_upload_with_tail( + tail: &[(&str, &str)], + later_upload_bytes: &[u8], + ) -> (String, Vec) { + let (boundary, mut body) = multipart_body_with_files( + &[ + ("name", "匿名"), + ("subject", "Subject "), + ("body", "Draft 日本語"), + ], + &[("file", "oversized.png", &[1; 2048], "image/png")], + ); + let closing = format!("--{boundary}--\r\n"); + body.truncate(body.len().saturating_sub(closing.len())); + let files = if later_upload_bytes.is_empty() { + Vec::new() + } else { + vec![("image_file", "later.png", later_upload_bytes, "image/png")] + }; + let (_, trailing) = multipart_body_with_files(tail, &files); + body.extend_from_slice(&trailing); + (boundary, body) + } + + #[tokio::test] + async fn rejected_upload_preserves_received_text_and_trailing_poll() -> anyhow::Result<()> { + let (boundary, body) = rejected_upload_with_tail( + &[ + ("sage", "1"), + ("poll_question", "Question 日本語?"), + ("poll_option", "One"), + ("poll_option", "Two"), + ("poll_option", "Three"), + ("poll_duration_value", "3"), + ("poll_duration_unit", "days"), + ("captcha_answer", "never retained"), + ("_csrf", "never retained"), + ], + b"later upload is discarded", + ); + let gate = crate::middleware::MediaUploadGate::new(); + let mut draft = crate::templates::forms::PostFormState::default(); + let result = parse_post_multipart( + multipart_from_bytes(&boundary, body).await?, + None, + 1024, + 1024, + 1024, + 1024, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut draft, + }, + ) + .await; + ensure!( + matches!(&result, Err(crate::error::AppError::UploadTooLarge(message)) if message.starts_with("File too large.")) + ); + ensure!( + draft.body == "Draft 日本語" + && draft.subject == "Subject " + && draft.name == "匿名" + ); + ensure!(draft.sage && draft.had_attachments && !draft.incomplete); + let poll = draft.poll.context("trailing poll not retained")?; + ensure!(poll.question == "Question 日本語?" && poll.options == ["One", "Two", "Three"]); + ensure!(poll.duration_value == "3" && poll.duration_unit == "days"); + Ok(()) + } + + #[tokio::test] + async fn rejected_upload_tail_shares_excess_budget_and_reports_partial_capture( + ) -> anyhow::Result<()> { + let bytes = vec![1; super::REJECTED_UPLOAD_DISCARD_MAX_BYTES]; + let (boundary, body) = + rejected_upload_with_tail(&[("poll_question", "Already captured?")], &bytes); + let gate = crate::middleware::MediaUploadGate::new(); + let mut draft = crate::templates::forms::PostFormState::default(); + let result = parse_post_multipart( + multipart_from_bytes(&boundary, body).await?, + None, + 1024, + 1024, + 1024, + 1024, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut draft, + }, + ) + .await; + ensure!( + matches!(&result, Err(crate::error::AppError::UploadTooLarge(message)) if message.starts_with("File too large.")), + "expected original board-limit rejection, received {:?}", + result.as_ref().err().map(ToString::to_string) + ); + drop(result); + ensure!(draft.body == "Draft 日本語" && draft.incomplete); + ensure!( + draft.poll.context("bounded earlier poll missing")?.question == "Already captured?" + ); + Ok(()) + } + + #[tokio::test] + async fn rejected_upload_tail_keeps_text_poll_and_field_limits() -> anyhow::Result<()> { + let long_text = "x".repeat(super::TEXT_MULTIPART_FIELD_MAX_BYTES + 1); + let long_question = "q".repeat(501); + let long_option = "o".repeat(201); + for tail in [ + vec![("poll_question", "q"); super::PUBLIC_MULTIPART_MAX_FIELDS], + vec![("poll_option", "option"); 21], + vec![("body", long_text.as_str())], + vec![("poll_question", long_question.as_str())], + vec![("poll_option", long_option.as_str())], + ] { + let (boundary, body) = rejected_upload_with_tail(&tail, &[]); + let gate = crate::middleware::MediaUploadGate::new(); + let mut draft = crate::templates::forms::PostFormState::default(); + let result = parse_post_multipart( + multipart_from_bytes(&boundary, body).await?, + None, + 1024, + 1024, + 1024, + 1024, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut draft, + }, + ) + .await; + ensure!( + matches!(&result, Err(crate::error::AppError::UploadTooLarge(message)) if message.starts_with("File too large.")) + ); + drop(result); + ensure!(draft.incomplete); + ensure!(draft + .poll + .as_ref() + .is_none_or(|poll| poll.options.len() <= 20)); + } + Ok(()) + } + + #[tokio::test] + async fn rejected_upload_tail_does_not_receive_a_second_timeout() -> anyhow::Result<()> { + let (boundary, body) = multipart_body_with_files(&[("body", "unread")], &[]); + let mut multipart = multipart_from_bytes(&boundary, body).await?; + let mut draft = crate::templates::forms::PostFormState { + body: "saved".into(), + incomplete: true, + ..Default::default() + }; + let mut budget = super::PublicMultipartBudget { + rejected_upload: Some(super::RejectedUploadRecovery { + bytes_seen: 1, + deadline: tokio::time::Instant::now(), + field_complete: true, + }), + ..Default::default() + }; + super::recover_rejected_post_tail(&mut multipart, &mut budget, &mut draft).await; + ensure!(draft.body == "saved" && draft.incomplete && budget.bytes_seen == 0); + Ok(()) + } + #[test] fn public_multipart_boundary_parser_accepts_quoted_parameter() -> anyhow::Result<()> { let parsed = super::parse_public_multipart_boundary( @@ -1555,7 +2082,10 @@ trailer << /Root 1 0 R >> 1_024, 5_000, 1_024, - &gate, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut crate::templates::forms::PostFormState::default(), + }, ) .await?; let (upload, _) = form.audio_file.as_ref().ok_or_else(|| { @@ -1582,7 +2112,10 @@ trailer << /Root 1 0 R >> 1_024, 5_000, 1_024, - &gate, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut crate::templates::forms::PostFormState::default(), + }, ) .await?; Ok("ok") @@ -1671,7 +2204,10 @@ trailer << /Root 1 0 R >> 1_024, 1_024, 1_024, - &gate, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut crate::templates::forms::PostFormState::default(), + }, ) .await?; Ok("ok") @@ -1688,7 +2224,10 @@ trailer << /Root 1 0 R >> 1_024, 1_024, 2_048, - &gate, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut crate::templates::forms::PostFormState::default(), + }, ) .await?; let (upload, _) = form.file.as_ref().ok_or_else(|| { @@ -1752,7 +2291,10 @@ trailer << /Root 1 0 R >> 1_024, 1_024, 1_024, - &gate, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut crate::templates::forms::PostFormState::default(), + }, ) .await?; @@ -1783,7 +2325,10 @@ trailer << /Root 1 0 R >> 1_024, 1_024, 1_024, - &gate, + super::PostMultipartContext { + media_upload_gate: &gate, + draft: &mut crate::templates::forms::PostFormState::default(), + }, ) .await?; diff --git a/src/handlers/render.rs b/src/handlers/render.rs index 6d55194d..d5e8eab5 100644 --- a/src/handlers/render.rs +++ b/src/handlers/render.rs @@ -273,6 +273,7 @@ mod tests { subject: Some("subject".into()), created_at: 100, bumped_at: 200, + last_post_at: 200, locked: false, sticky: false, archived: false, diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 2f810762..1c693b0e 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -316,7 +316,8 @@ pub(in crate::server) async fn post_reply( }; let csrf_cookie = jar.get("csrf_token").map(|c| c.value().to_owned()); - let form = tokio::time::timeout( + let mut parse_draft = crate::templates::forms::PostFormState::default(); + let parsed_form = tokio::time::timeout( crate::handlers::PUBLIC_UPLOAD_TIMEOUT, parse_post_multipart( multipart, @@ -325,22 +326,44 @@ pub(in crate::server) async fn post_reply( access_context.board.max_video_size_bytes(), access_context.board.max_audio_size_bytes(), access_context.board.max_pdf_size_bytes(), - &state.media_upload_gate, + crate::handlers::PostMultipartContext { + media_upload_gate: &state.media_upload_gate, + draft: &mut parse_draft, + }, ), ) .await - .map_err(|_error| AppError::BadRequest("Upload timed out. Please try again.".into()))??; + .map_err(|_error| AppError::BadRequest("Upload timed out. Please try again.".into())) + .and_then(std::convert::identity); + let form = match parsed_form { + Ok(form) => form, + Err(error) => { + if xhr_request { + return crate::handlers::board::xhr_post_error_response(error); + } + return Ok(crate::handlers::native_post_error_response( + error, + Some(&parse_draft), + )); + } + }; + + // Replies do not expose thread subject or poll controls. + parse_draft.subject.clear(); + parse_draft.poll = None; + let post_form_state = parse_draft; if !form.csrf_verified { - return Err(AppError::Forbidden("CSRF token mismatch.".into())); + let error = AppError::Forbidden("CSRF token mismatch. Reload the posting page for a fresh form after copying your draft.".into()); + if xhr_request { + return crate::handlers::board::xhr_post_error_response(error); + } + return Ok(crate::handlers::native_post_error_response( + error, + Some(&post_form_state), + )); } - let post_form_state = crate::templates::forms::PostFormState { - name: form.name.clone(), - subject: String::new(), - body: form.body.clone(), - sage: form.sage, - }; // Extract csrf_token before spawn_blocking so the ban page appeal form works. let ban_csrf_token = csrf_cookie.clone().unwrap_or_default(); @@ -424,7 +447,10 @@ pub(in crate::server) async fn post_reply( if xhr_request { return crate::handlers::board::xhr_post_error_response(error); } - return Err(error); + return Ok(crate::handlers::native_post_error_response( + error, + Some(&post_form_state), + )); } }; if xhr_request { diff --git a/src/lib.rs b/src/lib.rs index 90da68aa..3d995b9e 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -57,6 +57,8 @@ pub mod theme_builder; #[cfg(test)] /// TLS certificate handling used by crate-level tests. pub mod tls; +/// Signed release discovery and isolated native update transactions. +pub mod updates; /// Cryptographic, filesystem, sanitization, and redirect helpers. pub mod utils; #[cfg(test)] diff --git a/src/main.rs b/src/main.rs index ba1142f1..4a67f6bb 100644 --- a/src/main.rs +++ b/src/main.rs @@ -59,6 +59,8 @@ pub mod theme; pub mod theme_builder; /// TLS certificate loading and acceptor construction used by the server. pub mod tls; +/// Signed release discovery and isolated native update transactions. +pub mod updates; /// Cryptographic, filesystem, sanitization, and redirect helpers. pub mod utils; /// Background media and maintenance job processing. @@ -142,6 +144,20 @@ fn relaunch_in_terminal_if_needed() -> anyhow::Result { // CHAN_BLOCKING_THREADS environment variable. fn main() -> anyhow::Result<()> { + if std::env::args_os() + .nth(1) + .is_some_and(|arg| arg == "--update-info") + { + writeln!( + io::stdout().lock(), + "{}", + serde_json::json!({ + "version": env!("CARGO_PKG_VERSION"), "target": updates::platform_target(), + "schema": db::baseline_schema_version(), "minimum_schema": "1.5.0", "updater_protocol": 1 + }) + )?; + return Ok(()); + } // Parse before terminal attachment, filesystem creation, logging, settings // generation, or CONFIG access. Clap handles --help and --version here and // exits successfully without mutating runtime state. @@ -149,6 +165,13 @@ fn main() -> anyhow::Result<()> { config::configure_data_dir(cli.data_dir.as_deref())?; config::configure_port_override(cli.port)?; + if updates::managed() { + tokio::runtime::Builder::new_current_thread() + .enable_all() + .build()? + .block_on(updates::await_startup())?; + } + // Double-click / no-TTY guard // When launched from a file manager (Linux) or Explorer (Windows), stdout // is not a TTY. Re-attach to a terminal so the banner, first-run wizard, diff --git a/src/models.rs b/src/models.rs index ff6e3223..82f98b86 100644 --- a/src/models.rs +++ b/src/models.rs @@ -485,6 +485,8 @@ pub struct Thread { pub created_at: i64, /// Last bump time as a Unix timestamp. pub bumped_at: i64, + /// Latest post time, including replies that do not bump the thread. + pub last_post_at: i64, /// Whether new replies are prohibited. pub locked: bool, /// Whether the thread remains ahead of non-sticky threads. diff --git a/src/server/server.rs b/src/server/server.rs index 734dcae9..db8857c0 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -196,6 +196,7 @@ fn protect_tls_plaintext_backend( /// Returns an error when configuration validation, filesystem or database /// initialization, listener startup, or coordinated listener execution fails. pub async fn run_server(port_override: Option) -> anyhow::Result<()> { + crate::updates::await_startup().await?; // rustls 0.23 requires an explicit process-wide crypto provider. // install_default() is idempotent — a second call (e.g. in tests) returns // Err but never panics, so the let _ discard is intentional. diff --git a/src/server/server/lifecycle.rs b/src/server/server/lifecycle.rs index 84867a04..8e4b2950 100644 --- a/src/server/server/lifecycle.rs +++ b/src/server/server/lifecycle.rs @@ -14,6 +14,16 @@ pub(super) async fn track_requests( req: axum::extract::Request, next: axum::middleware::Next, ) -> axum::response::Response { + if crate::updates::managed() + && !matches!( + *req.method(), + axum::http::Method::GET | axum::http::Method::HEAD | axum::http::Method::OPTIONS + ) + && !crate::updates::mutations_allowed(Some(std::path::Path::new(crate::updates::SOCKET))) + .await + { + return recovery_unavailable(); + } REQUEST_COUNT.fetch_add(1, Ordering::Relaxed); IN_FLIGHT.fetch_add(1, Ordering::Relaxed); let _in_flight_guard = ScopedDecrement(&IN_FLIGHT); @@ -90,3 +100,39 @@ pub(super) async fn shutdown_signal() { () = terminate => tracing::info!(target: "server", signal = "SIGTERM", "Shutdown signal received"), } } + +/// Public write admission reports availability without exposing installation/recovery state. +fn recovery_unavailable() -> axum::response::Response { + use axum::response::IntoResponse as _; + ( + axum::http::StatusCode::SERVICE_UNAVAILABLE, + [("retry-after", "3")], + "Service temporarily unavailable. Retry shortly.", + ) + .into_response() +} + +#[cfg(test)] +/// Public admission privacy contract. +mod tests { + use super::*; + + /// Anonymous/API callers must not receive update phase, version or recovery details. + #[tokio::test] + async fn public_admission_error_keeps_updates_private() -> anyhow::Result<()> { + let response = recovery_unavailable(); + anyhow::ensure!( + response.status() == axum::http::StatusCode::SERVICE_UNAVAILABLE, + "unsafe writes must fail closed" + ); + let body = axum::body::to_bytes(response.into_body(), 4096).await?; + let text = std::str::from_utf8(&body)?; + anyhow::ensure!( + !text.contains("update") + && !text.contains("recovery") + && !text.contains(crate::updates::VERSION), + "public availability errors must not expose update state" + ); + Ok(()) + } +} diff --git a/src/server/server/observability.rs b/src/server/server/observability.rs index 4e67f8f5..1725c62c 100644 --- a/src/server/server/observability.rs +++ b/src/server/server/observability.rs @@ -31,6 +31,8 @@ struct HealthPayload { )] /// Detailed readiness response. struct ReadyPayload { + /// Running application package version. + version: &'static str, /// Aggregate readiness label. status: &'static str, /// Whether the database accepts queries and has the expected schema. @@ -60,6 +62,8 @@ struct ReadyPayload { #[derive(Serialize)] /// Public readiness response without operational internals. struct PublicReadyPayload { + /// Running application package version; no release-discovery metadata. + version: &'static str, /// Aggregate readiness label. status: &'static str, } @@ -104,6 +108,7 @@ async fn readyz_response(state: AppState, include_details: bool) -> Response { return ( status, Json(PublicReadyPayload { + version: env!("CARGO_PKG_VERSION"), status: status_label, }), ) @@ -172,6 +177,7 @@ async fn readyz_response(state: AppState, include_details: bool) -> Response { StatusCode::SERVICE_UNAVAILABLE }; let payload = ReadyPayload { + version: env!("CARGO_PKG_VERSION"), status: status_label, database_ready, database_schema_version: crate::db::baseline_schema_version(), @@ -392,7 +398,19 @@ mod tests { Some("ready"), "public readiness should include the aggregate status" ); + assert_eq!( + body.get("version").and_then(serde_json::Value::as_str), + Some(env!("CARGO_PKG_VERSION")), + "readiness must report only the running package version" + ); for field in [ + "latest_release", + "update_available", + "discovery", + "updater", + "backups", + "approval", + "phase", "database_schema_version", "database_schema_valid", "worker_queue_pending", diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs index 372b4a71..0d4e2f71 100644 --- a/src/server/server/routes.rs +++ b/src/server/server/routes.rs @@ -176,6 +176,18 @@ pub(super) fn public_routes() -> Router { /// Compose all authenticated administration route groups. pub(super) fn admin_routes() -> Router { Router::new() + .route( + "/admin/updates/status", + get(crate::handlers::admin::updates::status), + ) + .route( + "/admin/updates/check", + post(crate::handlers::admin::updates::check), + ) + .route( + "/admin/updates/install", + post(crate::handlers::admin::updates::install), + ) .merge(admin_auth_routes()) .route( "/admin/network/settings", diff --git a/src/templates/admin.rs b/src/templates/admin.rs index cf0b58b4..737ebe6f 100644 --- a/src/templates/admin.rs +++ b/src/templates/admin.rs @@ -88,6 +88,8 @@ mod network; mod search; /// Site-health rendering. mod site_health; +/// Software update and pre-upgrade backup views. +mod updates; /// Render authenticated network configuration status and controls. #[must_use] @@ -2012,6 +2014,23 @@ pub fn render_application_state( application::render(fields) } +/// Render administrator-only software status inside the existing admin workspace. +#[must_use] +pub fn render_software_updates( + status: &crate::updates::Status, + csrf: &str, + managed: bool, + container: bool, +) -> String { + updates::render(status, csrf, managed, container) +} + +/// Render pre-upgrade snapshot history inside the existing Backups workspace. +#[must_use] +pub fn render_update_backups(status: &crate::updates::Status) -> String { + updates::backups(status) +} + #[cfg(test)] mod tests { use super::{ diff --git a/src/templates/admin/backups.rs b/src/templates/admin/backups.rs index eb25b8ba..2d5e31e3 100644 --- a/src/templates/admin/backups.rs +++ b/src/templates/admin/backups.rs @@ -42,7 +42,7 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { )); } - render_admin_backups_section( + let section = render_admin_backups_section( view.csrf_token, &backup_warning_html, view.backups.backup_status_line, @@ -58,7 +58,8 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { &board_backup_cards, &render_full_backup_rows(view), &render_board_backup_rows(view), - ) + ); + format!("{section}") } #[expect( diff --git a/src/templates/admin/layout.rs b/src/templates/admin/layout.rs index af5e0825..de9f278d 100644 --- a/src/templates/admin/layout.rs +++ b/src/templates/admin/layout.rs @@ -39,6 +39,11 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { selected == "maintenance", ); + let update_section = task( + "software-updates", + "", + selected == "software-updates", + ); let body = format!( r#"
{flash} @@ -60,6 +65,7 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { {overview_section} {network_section} {runtime_sections} +{update_section} {boards_section} {moderation_section} {appearance_section} @@ -140,6 +146,7 @@ fn render_admin_section_index(open_section: Option<&str>) -> String { moderation appearance backups + Software Updates maintenance "##; let anchor = open_section @@ -207,6 +214,7 @@ fn default_task(open: Option<&str>) -> &'static str { Some(section) if section.starts_with("board-appearance-") => "appearance", Some(section) if section.starts_with("board-backup-") => "backups", Some(section) if section.starts_with("board-") => "boards", + Some("software-updates") => "software-updates", Some("boards") => "boards", Some("reports" | "moderation") => "moderation", Some( diff --git a/src/templates/admin/search.rs b/src/templates/admin/search.rs index c37d7c6a..2b4e0a9c 100644 --- a/src/templates/admin/search.rs +++ b/src/templates/admin/search.rs @@ -56,6 +56,7 @@ pub(super) fn render(query: Option<&str>) -> String { } } for (section, label, synonyms) in [ + ("software-updates", "Software Updates", "software update upgrade release version rollback"), ("accounts", "Administrator accounts and passwords", "accounts administrator create reset change password"), ("storage", "Storage relocation and secret rotation", "storage paths database uploads data directory relocate migrate cookie secret rotate CSRF"), ("visitor-defaults", "Default NSFW visibility", "default hide NSFW boards visibility visitor preference"), diff --git a/src/templates/admin/updates.rs b/src/templates/admin/updates.rs new file mode 100644 index 00000000..7e5da0a2 --- /dev/null +++ b/src/templates/admin/updates.rs @@ -0,0 +1,159 @@ +//! Software update controls rendered only inside the authorized administration shell. + +use crate::updates::{Discovery, Status}; +use crate::utils::sanitize::escape_html; +use std::fmt::Write as _; + +/// Render native update status or a clear deployment-managed check-only workflow. +pub(super) fn render(status: &Status, csrf: &str, managed: bool, container: bool) -> String { + let (latest, verification) = match &status.discovery { + Some(Discovery::Available(release)) => ( + escape_html(&release.version), + escape_html(&release.verification), + ), + Some(Discovery::UpToDate) => ( + "Up to date".to_owned(), + "No newer stable release found.".to_owned(), + ), + Some(Discovery::UnableToCheck(message)) => ("Unavailable".to_owned(), escape_html(message)), + None => ( + "Not checked".to_owned(), + "Check the official stable releases to see availability.".to_owned(), + ), + }; + let active = status.phase.active(); + let progress = if active { "true" } else { "false" }; + let mut controls = format!( + r#"
"#, + escape_html(csrf), + if active { " disabled" } else { "" } + ); + if managed { + if let Some(approval) = status.approval.as_deref().filter(|_| { + !active + && status + .available_release() + .is_some_and(|release| release.compatible) + }) { + let _ = write!( + controls, + r#"

Install verified update

A verified backup is mandatory. RustChan will briefly restart. Failed upgrades restore the previous software, database, configuration and persistent files.

Approval expires after 10 minutes and can be used once.

"#, + escape_html(csrf), + escape_html(approval) + ); + } + } else { + let note = if container { + "Container installation is deployment-managed. Pull the chosen image and recreate the container with its persistent data volume." + } else { + "Installation is deployment-managed on this system. Install the official release using your deployment tools. Native self-updates require the supported Linux updater service." + }; + let _ = write!(controls, "

{note}

"); + } + format!( + r#"

Software Updates

Stable releases from the official RustChan repository. Update checks and installation details are visible only to administrators.

Running version
{}
Latest stable version
{latest}
Last check
{}
Verification
{verification}
Previous result / current progress
{}
{controls}

If the server restarts, reconnect to Software Updates to see the persisted result. JavaScript reconnects automatically while an installation is active.

{}
"#, + escape_html(crate::updates::VERSION), + escape_html(status.checked_at.as_deref().unwrap_or("Not checked")), + escape_html(if status.message.is_empty() { + "No installation attempted." + } else { + &status.message + }), + backup_summary(status) + ) +} + +/// Keep backup metadata in the existing Backups task; updater-owned snapshots have no destructive web controls. +pub(super) fn backups(status: &Status) -> String { + format!( + r#"

Pre-upgrade backups

Verified snapshots retained for automatic rollback. Manual and scheduled backups remain available above. Update snapshots include the database, configuration, media and private runtime state; the updater controls restoration and retention.

{}
"#, + backup_summary(status) + ) +} + +/// Render bounded, escaped backup history without leaking absolute storage paths. +fn backup_summary(status: &Status) -> String { + if status.backups.is_empty() { + return "

No pre-upgrade snapshots retained.

".to_owned(); + } + let mut rows = String::new(); + for backup in status.backups.iter().take(20) { + let _ = write!( + rows, + "{}{} → {}{}{}", + escape_html(&backup.created_at), + escape_html(&backup.previous_version), + escape_html(&backup.target_version), + backup.size, + if backup.verified { + "Verified pre-upgrade backup" + } else { + "Verification failed" + } + ); + } + format!("
{rows}
CreatedUpgradeBytesVerification
") +} + +#[cfg(test)] +mod tests { + use super::*; + /// Old successful discovery cannot retain a badge or authorize a stale install form. + #[test] + fn availability_tracks_the_running_version() -> anyhow::Result<()> { + let mut candidate = semver::Version::parse(crate::updates::VERSION)?; + candidate.minor += 1; + candidate.patch = 0; + for (version, available) in [ + ("0.1.0".to_owned(), false), + (crate::updates::VERSION.to_owned(), false), + (candidate.to_string(), true), + (format!("{candidate}-rc.1"), false), + ("malformed".to_owned(), false), + ] { + let status = Status { + discovery: Some(Discovery::Available(Box::new(crate::updates::Release { + id: 42, + version, + published_at: String::new(), + notes: String::new(), + manifest: None, + size: None, + verification: String::new(), + compatible: true, + }))), + approval: Some("approval".to_owned()), + ..Status::default() + }; + anyhow::ensure!( + status.available_release().is_some() == available, + "availability must compare stable versions" + ); + anyhow::ensure!( + render(&status, "csrf", true, false).contains("id=\"admin-update-install\"") + == available, + "installation controls must share the availability gate" + ); + } + Ok(()) + } + + /// Check-only environments never display an install control even with forged availability metadata. + #[test] + fn container_never_renders_install() { + let status = Status { + phase: crate::updates::Phase::Idle, + approval: Some("approval".to_owned()), + ..Status::default() + }; + let html = render(&status, "csrf", false, true); + assert!( + !html.contains("id=\"admin-update-install\""), + "containers must not display installation controls" + ); + assert!( + html.contains("Container installation is deployment-managed"), + "containers should explain the upgrade procedure" + ); + } +} diff --git a/src/templates/board.rs b/src/templates/board.rs index 381fccec..80569ee7 100644 --- a/src/templates/board.rs +++ b/src/templates/board.rs @@ -537,7 +537,7 @@ fn render_catalog_card( }; format!( - r#"
+ r#"
{thumb} @@ -558,6 +558,7 @@ fn render_catalog_card( replies = thread.reply_count, created = thread.created_at, bumped = thread.bumped_at, + last_reply = thread.last_post_at, is_sticky = if thread.sticky { "1" } else { "0" }, is_pinned = if is_pinned { "1" } else { "0" }, board = escape_html(&board.short_name), @@ -1510,7 +1511,7 @@ pub fn search_page( escape_html(&result_label) ); for post in posts { - body.push_str(&super::thread::render_post( + body.push_str(&super::thread::render_search_post( post, &board.short_name, csrf_token, @@ -1528,7 +1529,6 @@ pub fn search_page( thread_op_id: None, video_audio_muted: user_preferences.video_audio_muted, }, - 0, )); } body.push_str(&render_pagination( @@ -1650,6 +1650,7 @@ mod tests { subject: Some("Thread subject".into()), created_at: 1_700_000_000, bumped_at: 1_700_000_100, + last_post_at: 1_700_000_100, locked: true, sticky: true, archived: false, diff --git a/src/templates/forms.rs b/src/templates/forms.rs index f1ceab09..939601b2 100644 --- a/src/templates/forms.rs +++ b/src/templates/forms.rs @@ -6,6 +6,7 @@ use crate::config::CONFIG; use crate::models::Board; use crate::utils::sanitize::escape_html; +use std::fmt::Write as _; /// User-entered fields preserved when a post form must be rendered again. #[derive(Clone, Debug, Default, PartialEq, Eq)] @@ -18,6 +19,54 @@ pub struct PostFormState { pub body: String, /// Whether the reply should avoid bumping its thread. pub sage: bool, + /// Poll draft entered for a new thread. + pub poll: Option, + /// Whether a rejected request contained attachments that must be reselected. + pub had_attachments: bool, + /// Whether parsing stopped before every submitted control could be captured. + pub incomplete: bool, +} + +/// Poll controls preserved independently of normalized server-side validation. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub struct PollFormState { + /// Poll question field. + pub question: String, + /// Nonempty poll option fields, in submitted order. + pub options: Vec, + /// Duration number as entered by the user. + pub duration_value: String, + /// Duration unit selected by the user. + pub duration_unit: String, +} + +/// Renders posting-only recovery advice and a copyable draft when available. +#[must_use] +pub fn post_error_recovery_body(message: &str, prefill: Option<&PostFormState>) -> String { + let draft = prefill.map_or_else(String::new, |state| { + let poll = state.poll.as_ref().map_or_else(String::new, |poll| { + let mut options = String::new(); + for (index, option) in poll.options.iter().enumerate() { + // Writing formatted text to a String cannot fail. + let _ = write!(options, "

Option {}: {}

", index + 1, escape_html(option)); + } + format!("

Poll: {}

{options}

Duration: {} {}

", + escape_html(&poll.question), escape_html(&poll.duration_value), escape_html(&poll.duration_unit)) + }); + let partial = if state.incomplete { "

Only controls received before the upload stopped are saved below. Check your name, subject, body, sage and poll options before retrying.

" } else { "" }; + format!(r#"{partial}
[ saved draft — copy before leaving ]
+

Name: {name}

Subject: {subject}

+ +

Sage: {sage}

{poll}
"#, + name = escape_html(&state.name), subject = escape_html(&state.subject), body = escape_html(&state.body), + sage = if state.sage { "yes" } else { "no" }) + }); + format!( + r#"

Post could not be completed

{message}

+

Copy the saved draft below before leaving. Your browser’s Back button may restore the original form, but some browsers clear it. Open a fresh posting form and paste your saved text if needed. Choose attachments again; browsers cannot restore file selections on a new page.

+

If the result is uncertain, check the thread or board for your post before submitting again.

{draft}

return home

"#, + message = escape_html(message) + ) } /// Upload capabilities needed to choose the form's media controls. @@ -37,8 +86,8 @@ const fn upload_progress_row() -> &'static str { upload
-
-
Preparing upload…
+ +
Preparing upload…
"# } @@ -107,6 +156,7 @@ fn render_captcha_row(board_short: &str, reply_suffix: &str, refresh_href: &str) Enter the text shown in the image. If it expires or fails, request a new challenge. + "#, board = board, suffix = reply_suffix, @@ -118,9 +168,10 @@ fn render_captcha_row(board_short: &str, reply_suffix: &str, refresh_href: &str) } /// Renders one numbered poll-option input row. -fn render_poll_option_row(option_number: usize) -> String { +fn render_poll_option_row(option_number: usize, value: &str) -> String { format!( - r#"
"# + r#"
"#, + value = escape_html(value), ) } @@ -261,6 +312,83 @@ fn render_single_upload_row(board: &Board, audio_image_hint: &str) -> String { ) } +/// Renders the shared poll creator, including native additional-option controls. +fn render_poll_creator(poll: Option<&PollFormState>) -> String { + let visible_option_count = poll.map_or(2, |state| { + state.options.len().clamp(2, POLL_OPTION_MAX_COUNT) + }); + let poll_option_rows: String = (1..=visible_option_count) + .map(|number| { + render_poll_option_row( + number, + poll.and_then(|state| state.options.get(number - 1)) + .map_or("", String::as_str), + ) + }) + .collect(); + let extra_poll_option_rows: String = (visible_option_count + 1..=POLL_OPTION_MAX_COUNT) + .map(|number| render_poll_option_row(number, "")) + .collect(); + let poll_question = poll.map_or("", |state| state.question.as_str()); + let poll_open = + if poll.is_some_and(|state| !state.question.is_empty() || !state.options.is_empty()) { + " open" + } else { + "" + }; + let duration_value = poll.map_or("24", |state| state.duration_value.as_str()); + let duration_unit = poll.map_or("hours", |state| state.duration_unit.as_str()); + format!( + r#" + + Poll +
+ [ 📊 Add a Poll to this thread ] +
+
+ +
+
+ {poll_option_rows} + +
+ +
+ +
+
+
+ "#, + poll_option_max_length = POLL_OPTION_MAX_LENGTH, + poll_option_max_count = POLL_OPTION_MAX_COUNT, + poll_option_rows = poll_option_rows, + poll_question = escape_html(poll_question), + duration_value = escape_html(duration_value), + hours_selected = if duration_unit == "hours" { + " selected" + } else { + "" + }, + minutes_selected = if duration_unit == "minutes" { + " selected" + } else { + "" + }, + days_selected = if duration_unit == "days" { + " selected" + } else { + "" + }, + ) +} + /// New-thread submission form. Embedded on board index and catalog pages. pub(super) fn new_thread_form( board_short: &str, @@ -289,10 +417,8 @@ pub(super) fn new_thread_form( String::new() }; - let poll_option_rows = [render_poll_option_row(1), render_poll_option_row(2)].concat(); - let extra_poll_option_rows: String = (3..=POLL_OPTION_MAX_COUNT) - .map(render_poll_option_row) - .collect(); + let poll_creator = render_poll_creator(prefill.and_then(|state| state.poll.as_ref())); + let attachment_recovery = attachment_recovery_row(prefill); let name_value = prefill.map_or("", |state| state.name.as_str()); let subject_value = prefill.map_or("", |state| state.subject.as_str()); let body_value = prefill.map_or("", |state| state.body.as_str()); @@ -323,35 +449,10 @@ pub(super) fn new_thread_form( {uploads_disabled_row} {upload_row} + {attachment_recovery} {upload_progress_row} {captcha_row} - - - Poll -
- [ 📊 Add a Poll to this thread ] -
-
- -
-
- {poll_option_rows} - -
- -
- -
-
-
- + {poll_creator}
@@ -366,12 +467,18 @@ pub(super) fn new_thread_form( upload_row = upload_row, upload_progress_row = upload_progress_row(), captcha_row = captcha_row, - poll_option_max_length = POLL_OPTION_MAX_LENGTH, - poll_option_max_count = POLL_OPTION_MAX_COUNT, - poll_option_rows = poll_option_rows, ) } +/// Explains the browser file-input restriction after a rejected native submission. +fn attachment_recovery_row(prefill: Option<&PostFormState>) -> &'static str { + if prefill.is_some_and(|state| state.had_attachments) { + r#"attachmentsChoose your attachments again. Your browser cannot restore file selections after this page reload."# + } else { + "" + } +} + /// Reply form injected into thread pages. pub(super) fn reply_form( board_short: &str, @@ -403,6 +510,7 @@ pub(super) fn reply_form( } else { String::new() }; + let attachment_recovery = attachment_recovery_row(prefill); let name_value = prefill.map_or("", |state| state.name.as_str()); let body_value = prefill.map_or("", |state| state.body.as_str()); let sage_checked = if prefill.is_some_and(|state| state.sage) { @@ -425,6 +533,7 @@ pub(super) fn reply_form( {uploads_disabled_row} {upload_row} + {attachment_recovery} {upload_progress_row} options Options @@ -450,7 +559,8 @@ pub(super) fn reply_form( mod tests { use super::{ build_upload_form_policy, new_thread_form, render_captcha_row, render_poll_option_row, - reply_form, PostFormState, AUDIO_ACCEPT, POLL_OPTION_MAX_COUNT, POLL_OPTION_MAX_LENGTH, + reply_form, PollFormState, PostFormState, AUDIO_ACCEPT, POLL_OPTION_MAX_COUNT, + POLL_OPTION_MAX_LENGTH, }; fn uploads_disabled_board() -> crate::models::Board { @@ -550,7 +660,7 @@ mod tests { #[test] fn poll_option_rows_share_the_same_max_length() { - let initial_row = render_poll_option_row(1); + let initial_row = render_poll_option_row(1, ""); assert!(initial_row.contains(&format!(r#"maxlength="{POLL_OPTION_MAX_LENGTH}""#))); let html = new_thread_form("test", "csrf", &uploads_disabled_board(), None, "/test"); @@ -588,6 +698,7 @@ mod tests { subject: "subject".into(), body: "draft body".into(), sage: true, + ..PostFormState::default() }; let thread_html = new_thread_form("test", "csrf", &board, Some(&state), "/test"); let reply_html = reply_form("test", 42, "csrf", &board, Some(&state)); @@ -606,6 +717,73 @@ mod tests { assert!(reply_html.contains(r#"name="sage" value="1" checked"#)); } + #[test] + fn rejected_thread_retains_escaped_poll_controls_and_extra_options() { + let state = PostFormState { + poll: Some(PollFormState { + question: " & 日本語".into(), + options: vec!["First".into(), "".into(), "Third".into()], + duration_value: "3".into(), + duration_unit: "days".into(), + }), + ..PostFormState::default() + }; + let html = new_thread_form( + "test", + "csrf", + &uploads_disabled_board(), + Some(&state), + "/test", + ); + + assert!(html.contains(r#"class="poll-creator" open"#)); + assert!(html.contains(r#"name="poll_question" value="<question> & 日本語""#)); + assert!(html.contains(r#"name="poll_option" value="<second>""#)); + assert!(html.contains(r#"name="poll_duration_value" value="3""#)); + assert!(html.contains(r#""#)); + let third_option = html.find(r#"name="poll_option" value="Third""#); + assert!(third_option.is_some()); + assert!(third_option < html.find("