diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index f2f801b..bbd08c0 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.91 - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install Rust 1.99 + uses: dtolnay/rust-toolchain@1.99.0 with: components: rustfmt - name: Check formatting @@ -34,8 +34,8 @@ jobs: uses: actions/checkout@v4 - name: Install Linux build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.91 - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install Rust 1.99 + uses: dtolnay/rust-toolchain@1.99.0 with: components: clippy - name: Cache Cargo @@ -43,26 +43,37 @@ jobs: with: cache-bin: false - name: Run Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings strict-clippy: - name: Strict Clippy + name: Strict Clippy (${{ matrix.rust }}) + strategy: + fail-fast: false + matrix: + rust: ["1.99.0", "stable"] runs-on: ubuntu-24.04 + env: + RUSTUP_TOOLCHAIN: ${{ matrix.rust }} steps: - name: Checkout uses: actions/checkout@v4 - name: Install Linux build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.91 - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install selected Rust + uses: dtolnay/rust-toolchain@master with: + toolchain: ${{ matrix.rust }} components: clippy - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: cache-bin: false - name: Run strict Clippy - run: cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo + run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo + + - name: Test latest stable + if: matrix.rust == 'stable' + run: cargo test --locked --workspace --all-features test: name: Test (${{ matrix.name }}) @@ -85,13 +96,32 @@ jobs: - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.91 - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install Rust 1.99 + uses: dtolnay/rust-toolchain@1.99.0 - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: cache-bin: false - name: Run tests - run: cargo test --workspace --all-features + run: cargo test --locked --workspace --all-features - name: Build CLI - run: cargo build --workspace --all-features --bin rustpost-cli + run: cargo build --locked --workspace --all-features --bin rustpost-cli + + release-tools: + name: Signed update packaging tests + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - name: Validate release signatures and rejection paths + run: python3 tools/test-release-tools.py + + msrv: + name: Rust 1.91 compatibility + env: + RUSTUP_TOOLCHAIN: "1.91.0" + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: dtolnay/rust-toolchain@1.91.0 + - name: Check minimum supported Rust + run: cargo check --locked --workspace --all-features diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml index 0c818b2..a2af2cb 100644 --- a/.github/workflows/container.yml +++ b/.github/workflows/container.yml @@ -1,7 +1,17 @@ name: Container on: + push: + tags: ['v*'] workflow_dispatch: + inputs: + tag: + description: Published source tag to build (for example v1.0.0) + required: true + type: string + +env: + RUSTPOST_RELEASE_TAG: ${{ inputs.tag || github.ref_name }} permissions: contents: read @@ -21,10 +31,14 @@ jobs: runs-on: ${{ matrix.runner }} steps: - uses: actions/checkout@v4 - - name: Verify v1.0.0 application source + with: + ref: ${{ env.RUSTPOST_RELEASE_TAG }} + - name: Resolve and validate version + id: version run: | - git fetch --depth=1 origin tag v1.0.0 - git diff --quiet v1.0.0 -- Cargo.toml Cargo.lock src + version="${RUSTPOST_RELEASE_TAG#v}" + test "$(python3 -c 'import tomllib; print(tomllib.load(open("Cargo.toml", "rb"))["package"]["version"])')" = "$version" + echo "value=$version" >> "$GITHUB_OUTPUT" - uses: docker/setup-buildx-action@v3 - uses: docker/login-action@v3 with: @@ -36,7 +50,8 @@ jobs: context: . platforms: linux/${{ matrix.arch }} push: true - tags: ghcr.io/csd113/rustpost:1.0.0-${{ matrix.arch }} + tags: ghcr.io/csd113/rustpost:${{ steps.version.outputs.value }}-${{ matrix.arch }} + build-args: RUSTPOST_VERSION=${{ steps.version.outputs.value }} provenance: false publish: @@ -52,10 +67,11 @@ jobs: password: ${{ secrets.GITHUB_TOKEN }} - name: Create multi-platform tags run: | - docker buildx imagetools create \ - --tag ghcr.io/csd113/rustpost:1.0.0 \ - --tag ghcr.io/csd113/rustpost:latest \ - ghcr.io/csd113/rustpost:1.0.0-amd64 \ - ghcr.io/csd113/rustpost:1.0.0-arm64 + version="${RUSTPOST_RELEASE_TAG#v}" + tags=(--tag "ghcr.io/csd113/rustpost:$version") + if [[ "$version" != *-* ]]; then tags+=(--tag ghcr.io/csd113/rustpost:latest); fi + docker buildx imagetools create "${tags[@]}" \ + "ghcr.io/csd113/rustpost:$version-amd64" \ + "ghcr.io/csd113/rustpost:$version-arm64" - name: Inspect published image - run: docker buildx imagetools inspect ghcr.io/csd113/rustpost:1.0.0 + run: docker buildx imagetools inspect "ghcr.io/csd113/rustpost:${RUSTPOST_RELEASE_TAG#v}" diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 6bad4b5..7f5d06c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,8 @@ jobs: - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.91 - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install Rust 1.99 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: ${{ matrix.target }} - name: Cache Cargo @@ -46,25 +46,30 @@ jobs: with: cache-bin: false - name: Build release binary - run: cargo build --release --locked --target ${{ matrix.target }} --bin rustpost-cli + run: cargo build --release --locked --target ${{ matrix.target }} --bins - name: Package archive run: | set -euo pipefail staging="dist/${{ matrix.name }}/rustpost" mkdir -p "$staging" + test "$(target/${{ matrix.target }}/release/rustpost-cli --version)" = "rustpost-cli ${GITHUB_REF_NAME#v}" cp "target/${{ matrix.target }}/release/rustpost-cli" "$staging/" + cp "target/${{ matrix.target }}/release/rustpost-updater" "$staging/" + if [ "${{ runner.os }}" = "Linux" ] && [[ "$GITHUB_REF_NAME" != *-* ]]; then + python3 tools/package-update.py --binary "target/${{ matrix.target }}/release/rustpost-cli" --target "${{ matrix.target }}" --version "$GITHUB_REF_NAME" --output dist + fi cp README.md "$staging/" if [ -f LICENSE ]; then cp LICENSE "$staging/"; fi if [ -f NOTICE ]; then cp NOTICE "$staging/"; fi if [ -f NOTICES ]; then cp NOTICES "$staging/"; fi tar -czf "dist/${{ matrix.archive }}" -C "dist/${{ matrix.name }}" rustpost (cd dist && shasum -a 256 "${{ matrix.archive }}" > "${{ matrix.archive }}.sha256") - - name: Upload release assets - uses: softprops/action-gh-release@v2 + - name: Upload built assets + uses: actions/upload-artifact@v4 with: - files: | - dist/${{ matrix.archive }} - dist/${{ matrix.archive }}.sha256 + name: ${{ matrix.name }} + path: dist/*.* + if-no-files-found: error build-windows: name: Build windows-x86_64 @@ -72,8 +77,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.91 - uses: dtolnay/rust-toolchain@1.91.0 + - name: Install Rust 1.99 + uses: dtolnay/rust-toolchain@1.99.0 with: targets: x86_64-pc-windows-msvc - name: Cache Cargo @@ -88,6 +93,8 @@ jobs: $ErrorActionPreference = "Stop" $staging = "dist/windows-x86_64/rustpost" New-Item -ItemType Directory -Force -Path $staging | Out-Null + $version = $env:GITHUB_REF_NAME.TrimStart("v") + if ((& target/x86_64-pc-windows-msvc/release/rustpost-cli.exe --version) -ne "rustpost-cli $version") { throw "Tag does not match executable" } Copy-Item "target/x86_64-pc-windows-msvc/release/rustpost-cli.exe" $staging Copy-Item "README.md" $staging if (Test-Path "LICENSE") { Copy-Item "LICENSE" $staging } @@ -95,9 +102,57 @@ jobs: if (Test-Path "NOTICES") { Copy-Item "NOTICES" $staging } Compress-Archive -Path $staging -DestinationPath "dist/rustpost-windows-x86_64.zip" -Force (Get-FileHash "dist/rustpost-windows-x86_64.zip" -Algorithm SHA256).Hash.ToLower() + " rustpost-windows-x86_64.zip" | Out-File -Encoding ascii "dist/rustpost-windows-x86_64.zip.sha256" - - name: Upload release assets - uses: softprops/action-gh-release@v2 + - name: Upload built assets + uses: actions/upload-artifact@v4 with: - files: | - dist/rustpost-windows-x86_64.zip - dist/rustpost-windows-x86_64.zip.sha256 + name: windows-x86_64 + path: dist/*.* + if-no-files-found: error + + publish: + name: Verify, sign and publish complete release + needs: [build-unix, build-windows] + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + - uses: actions/download-artifact@v4 + with: + path: dist + merge-multiple: true + - name: Sign and publish stable release + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RUSTPOST_UPDATE_SIGNING_KEY: ${{ secrets.RUSTPOST_UPDATE_SIGNING_KEY }} + shell: bash + run: | + set -euo pipefail + if [[ "$GITHUB_REF_NAME" != *-* ]]; then test -n "$RUSTPOST_UPDATE_SIGNING_KEY"; fi + key="$RUNNER_TEMP/rustpost-update-signing.pem" + trap 'rm -f "$key"' EXIT + umask 077 + printf '%s\n' "$RUSTPOST_UPDATE_SIGNING_KEY" > "$key" + unset RUSTPOST_UPDATE_SIGNING_KEY + for archive in rustpost-linux-x86_64.tar.gz rustpost-linux-aarch64.tar.gz rustpost-macos-aarch64.tar.gz rustpost-windows-x86_64.zip; do + test -s "dist/$archive" + (cd dist && sha256sum -c "$archive.sha256") + done + if [[ "$GITHUB_REF_NAME" == *-* ]]; then + if gh release view "$GITHUB_REF_NAME" --json isDraft --jq '.isDraft' > "$RUNNER_TEMP/is-draft"; then + test "$(cat "$RUNNER_TEMP/is-draft")" = true + else + gh release create "$GITHUB_REF_NAME" --draft --prerelease --verify-tag --generate-notes + fi + gh release upload "$GITHUB_REF_NAME" dist/*.tar.gz dist/*.zip dist/*.sha256 --clobber + gh release edit "$GITHUB_REF_NAME" --draft=false --prerelease --latest=false + exit 0 + fi + test -s "$key" + if gh release view "$GITHUB_REF_NAME" --json isDraft --jq '.isDraft' > "$RUNNER_TEMP/is-draft"; then + test "$(cat "$RUNNER_TEMP/is-draft")" = true + else + gh release create "$GITHUB_REF_NAME" --draft --verify-tag --generate-notes + fi + release_id=$(gh api "repos/$GITHUB_REPOSITORY/releases/tags/$GITHUB_REF_NAME" --jq '.id') + python3 tools/sign-updates.py --directory dist --key "$key" --release-id "$release_id" --version "$GITHUB_REF_NAME" + gh release upload "$GITHUB_REF_NAME" dist/*.tar.gz dist/*.zip dist/*.sha256 dist/rustpost-update-*.json dist/rustpost-update-*.sig --clobber + gh release edit "$GITHUB_REF_NAME" --draft=false --latest diff --git a/.gitignore b/.gitignore index 2d78a84..491c6f1 100644 --- a/.gitignore +++ b/.gitignore @@ -80,3 +80,14 @@ uploads/**/* !/tests/ !/tests/playwright/ /tests/playwright/* + +# Durable configuration-console regression suite +!/tests/playwright/configuration-admin.spec.mjs +!/tests/playwright/configuration-admin.config.mjs +!/tests/playwright/configuration-admin.helpers.mjs + +# Durable software-update browser regression suite +!/tests/playwright/software-updates.spec.mjs +!/tests/playwright/software-updates.config.mjs + +!tests/playwright/software-updates.helpers.mjs diff --git a/Cargo.lock b/Cargo.lock index d7eb54f..c0e07fd 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3410,9 +3410,12 @@ dependencies = [ "html-escape", "http-body-util", "infer", + "ring", "rusqlite", + "rustix", "rustls", "safelog", + "semver", "serde", "serde_json", "sha2 0.11.0", diff --git a/Cargo.toml b/Cargo.toml index d809062..1380b33 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -14,6 +14,10 @@ autobins = false name = "rustpost-cli" path = "src/bin/rustpost-cli.rs" +[[bin]] +name = "rustpost-updater" +path = "src/bin/rustpost-updater.rs" + [dependencies] anyhow = "1.0.104" arti-client = { version = "=0.46.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } @@ -31,6 +35,9 @@ getrandom = "0.4.3" safelog = "0.9.1" serde = { version = "1.0.229", features = ["derive"] } serde_json = "1.0.151" +semver = "1.0.28" +ring = "0.17.14" +rustix = { version = "1.1.5", features = ["fs"] } sha2 = "0.11.0" rusqlite = { version = "0.36.0", features = ["bundled", "functions"] } rustls = { version = "0.23.40", default-features = false, features = ["ring"] } diff --git a/Dockerfile b/Dockerfile index b5852ae..99d24c4 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,8 +1,12 @@ -FROM rust:1.91-bookworm AS build +# Bootstrap from the published Debian 12 image; install the exact release compiler. +FROM rust:1.98.1-bookworm AS build +RUN rustup toolchain install 1.99.0 --profile minimal --component rustfmt --component clippy \ + && rustup default 1.99.0 WORKDIR /usr/src/rustpost COPY Cargo.toml Cargo.lock ./ COPY src ./src +COPY assets ./assets RUN cargo build --release --locked --bin rustpost-cli FROM debian:bookworm-slim @@ -18,10 +22,12 @@ RUN apt-get update \ COPY --from=build /usr/src/rustpost/target/release/rustpost-cli /usr/local/bin/rustpost-cli COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh +ARG RUSTPOST_VERSION=1.0.0 LABEL org.opencontainers.image.source="https://github.com/csd113/RustPost" \ org.opencontainers.image.title="RustPost" \ - org.opencontainers.image.version="1.0.0" + org.opencontainers.image.version="${RUSTPOST_VERSION}" +ENV RUSTPOST_CONTAINER=1 USER rustpost VOLUME /data EXPOSE 8080 diff --git a/README.md b/README.md index d78dee6..e4ae267 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,9 @@ Installing `ffmpeg` is optional for downloaded apps. Without it, RustPost can se ### Build from source -With [Rust 1.91 or newer](https://rustup.rs/) installed, run these commands from the repository folder: +With [rustup](https://rustup.rs/) installed, run these commands from the repository folder (rustup selects the pinned compiler): + +Development and release builds use the Rust 1.99.0 pin in `rust-toolchain.toml`. CI also checks the latest `stable` compiler so future compiler and Clippy changes are reviewed before updating the release pin. After each stable release, install that exact version with rustup, update the toolchain/CI/container pins together, and pass the full validation gates before using it for releases. `Cargo.toml` keeps Rust 1.91 as the minimum supported Rust version (MSRV); it is a compatibility floor, not the build compiler pin. Existing toolchains and the global rustup default need no changes. ```sh cargo build --release --locked @@ -230,3 +232,7 @@ GitHub Actions also builds and tests on Linux x86_64, Linux ARM64, macOS Apple S ## License RustPost is available under the [MIT License](LICENSE). + +### Administrator software updates + +[Software update guide](docs/software-updates.md) covers panel checks, signed native Linux installation, mandatory verified backups, automatic rollback, restricted updater deployment, and release-signing setup. Containers and unmanaged installations use their deployment mechanism. diff --git a/assets/rustpost-updates.js b/assets/rustpost-updates.js new file mode 100644 index 0000000..c2cab6c --- /dev/null +++ b/assets/rustpost-updates.js @@ -0,0 +1,58 @@ +(() => { + const script = document.currentScript; + const progress = document.getElementById('update-progress'); + const form = document.getElementById('update-install'); + const finished = ['succeeded', 'rolled_back', 'failed', 'failed_manual_intervention']; + let polling = script?.dataset.active === 'true'; + let observedJob = polling; + let submitting = false; + async function poll() { + if (!polling) return; + try { + const response = await fetch('/admin/updates/status', { cache: 'no-store' }); + if (response.ok) { + const status = await response.json(); + if (!finished.includes(status.phase) && status.phase !== 'idle') observedJob = true; + if (finished.includes(status.phase) && (observedJob || !submitting)) { + location.replace('/admin/updates'); + return; + } + if (progress) progress.textContent = status.message || 'Update in progress.'; + } else if (progress) progress.textContent = 'RustPost is restarting. Reconnecting…'; + } catch { + if (progress) progress.textContent = 'RustPost is restarting. Reconnecting…'; + } + setTimeout(poll, 3000); + } + form?.addEventListener('submit', async (event) => { + event.preventDefault(); + if (submitting || polling) return; + submitting = true; + if (progress) progress.textContent = 'Starting the update. Reconnecting automatically after restart…'; + const button = form.querySelector('button[type="submit"]'); + if (button) button.disabled = true; + // Keep this already loaded document across the outage. Same-origin POST + // retains server-side auth/CSRF/password checks and ordinary no-JS fallback. + try { + const response = await fetch(form.action, { + method: 'POST', body: new URLSearchParams(new FormData(form)), redirect: 'follow' + }); + if (!response.ok) { + const text = await response.text(); + const document = new DOMParser().parseFromString(text, 'text/html'); + if (progress) progress.textContent = document.querySelector('[role="alert"] p, .error-panel h1 + p')?.textContent.trim() || 'Update request rejected. Reload to try again.'; + submitting = false; + if (button) button.disabled = false; + return; + } + } catch { + // The authenticated request may already have started the restart. + } finally { + form.querySelector('input[type="password"]').value = ''; + } + submitting = false; + polling = true; + poll(); + }); + if (polling) setTimeout(poll, 3000); +})(); diff --git a/deploy/systemd/49-rustpost-updater.rules b/deploy/systemd/49-rustpost-updater.rules new file mode 100644 index 0000000..3ba417e --- /dev/null +++ b/deploy/systemd/49-rustpost-updater.rules @@ -0,0 +1,10 @@ +// Only the updater account can start/stop the single installed RustPost unit. +// The web account gets no systemd/polkit authority. +polkit.addRule(function(action, subject) { + if (subject.user === "rustpost-updater" && + action.id === "org.freedesktop.systemd1.manage-units" && + action.lookup("unit") === "rustpost.service" && + (action.lookup("verb") === "start" || action.lookup("verb") === "stop")) { + return polkit.Result.YES; + } +}); diff --git a/deploy/systemd/rustpost-updater.service b/deploy/systemd/rustpost-updater.service new file mode 100644 index 0000000..4027872 --- /dev/null +++ b/deploy/systemd/rustpost-updater.service @@ -0,0 +1,27 @@ +[Unit] +Description=Restricted RustPost software updater +After=network-online.target polkit.service +Wants=network-online.target + +[Service] +User=rustpost-updater +Group=rustpost +UMask=0007 +RuntimeDirectory=rustpost-updater +RuntimeDirectoryMode=0750 +ExecStart=/usr/local/libexec/rustpost-updater --config /etc/rustpost-updater/updater.toml +Restart=on-failure +RestartSec=3 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +ReadWritePaths=/opt/rustpost /var/lib/rustpost /var/lib/rustpost-updater /run/rustpost-updater + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/rustpost.service b/deploy/systemd/rustpost.service new file mode 100644 index 0000000..7d16ea0 --- /dev/null +++ b/deploy/systemd/rustpost.service @@ -0,0 +1,28 @@ +[Unit] +Description=RustPost web application +After=network-online.target rustpost-updater.service +Wants=network-online.target +Wants=rustpost-updater.service + +[Service] +User=rustpost +Group=rustpost +Environment=RUSTPOST_MANAGED=1 +UMask=0007 +ExecStart=/opt/rustpost/current/rustpost-cli --data-dir /var/lib/rustpost --config /var/lib/rustpost/settings.toml serve +Restart=on-failure +RestartSec=5 +TimeoutStopSec=60 +NoNewPrivileges=true +ProtectSystem=strict +ProtectHome=true +PrivateTmp=true +ProtectKernelTunables=true +ProtectKernelModules=true +ProtectControlGroups=true +RestrictSUIDSGID=true +CapabilityBoundingSet= +ReadWritePaths=/var/lib/rustpost + +[Install] +WantedBy=multi-user.target diff --git a/deploy/systemd/updater.toml b/deploy/systemd/updater.toml new file mode 100644 index 0000000..04b4136 --- /dev/null +++ b/deploy/systemd/updater.toml @@ -0,0 +1,10 @@ +# Root-owned, mode 0644. Never expose this file in the admin settings editor. +install_dir = "/opt/rustpost" +state_dir = "/var/lib/rustpost-updater" +data_dir = "/var/lib/rustpost" +settings_path = "/var/lib/rustpost/settings.toml" +health_port = 8080 +# Replace with `id -u rustpost` when provisioning. +web_uid = 10001 +public_key = "/etc/rustpost-updater/public-key.hex" +retention = 5 diff --git a/docs/admin-configuration-audit.md b/docs/admin-configuration-audit.md new file mode 100644 index 0000000..d8cd14d --- /dev/null +++ b/docs/admin-configuration-audit.md @@ -0,0 +1,125 @@ +# Configuration audit and administration contract + +The source of truth is `Settings` in `src/config.rs`, its generated commented TOML template, validation, and actual runtime consumers. The inventory below covers **65 TOML options**. **30** were previously editable (24 in deep settings, six in backups); **31** are newly exposed. **61** are now editable and four are intentionally deployment-managed. Five additional site controls (four SQLite instance values and a favicon file) were already administered through the overview, bringing persisted site configuration to **70 options** and prior web coverage to **35**. + +## Sources and precedence + +`Settings::default()` generates first-run settings. Existing TOML deserializes typed required fields, with serde defaults for backward-compatible additions listed below. The CLI validates the result before using it. CLI `--config` selects a file (the editor uses the same resolved `RuntimePaths.settings_path`); `--data-dir` selects storage. Neither overrides field values. There are **no per-setting environment or CLI overrides**. Admin displays the resolved file and saved/running distinctions; ordinary startup settings apply after restart. + +There are four additional startup/test controls outside those 70 persisted options: `RUST_LOG` → tracing `EnvFilter` at startup (default `rustpost=info,tower_http=info`), CLI `--config`, CLI `--data-dir`, and debug-only `RUSTPOST_E2E_CAPTCHA_ANSWER` → registration challenge generation (five allowed CAPTCHA characters; not compiled into release behavior). These remain process/deployment/test controls: a web editor cannot change process environment or relocate its configuration/storage safely. Changing log filters can disclose operational detail or overwhelm output and must follow the deployment's logging policy. One-off backup/restore `--include-tor-keys` and `--allow-tor-keys` are operation consent flags, not persistent settings; existing backup UI provides equivalent explicit consent. + +`Settings::load` parses; callers validate before mutation or startup. TOML required fields stay required. Defaults and key names are unchanged. An absent backup table defaults to `backups`; an existing backup table omitting `backup_dir` retains its existing serde default (empty path, the data root). The editor preserves that historical distinction. + +## Model contract and safety + +`src/config/admin_fields.rs` defines one typed field contract. Its macro generates form fields, enums, metadata, parsing, exact display values and typed getters/setters that name the actual configuration members. Defaults come directly from `Settings::default`. TOML and form submissions use the same `Settings::validate`. An exhaustive serialized-model coverage test requires the union of editable fields and documented exclusions to equal every model leaf, prohibits duplicate keys/form names, and round-trips every default field. A model addition or serde rename therefore fails coverage instead of silently disappearing from admin. + +The writer replaces only changed, approved TOML value spans, including multiline values and quoted keys; unrelated keys, sections and adjacent comments survive. Comments inside a replaced array/string are part of that replaced value. Unchanged fields preserve their original spelling and formatting. Missing defaulted fields/sections are inserted. Both configuration and backup pages use this writer. A same-directory unique tempfile, inherited permissions, file sync and atomic replace prevent truncated files. Unix directory sync follows the replace. Non-regular destination files are rejected; temporary files clean themselves up on failure. All values validate before any write. + +Web writers share a mutex. Settings forms carry a SHA-256 revision covering the original file and require fresh review if another save changed it. External file edits are detected at form submission, but operators should still avoid editing the file concurrently during the brief write itself; no portable filesystem compare-and-swap exists here. Authorization and CSRF precede writes; unknown/duplicate fields and incomplete required values fail deserialization. Normal unchecked checkboxes deserialize as false. Invalid values preserve the other submitted values in an escaped, accessible form. + +There are no editable secret-valued configuration fields in this model: no SMTP, database credentials, API credentials, encryption keys or signing keys. The editor never reads onion private-key contents or database password hashes. Unknown TOML extension values are preserved and never displayed. The automatic-backup private-key flag controls inclusion, not secret disclosure; its help explains that archives contain sensitive private keys. + +## Timing and interface + +Nine sections: Site, Accounts, Posts, Media, Networking & cookies, Rate limiting, Onion service, Backups, Administration. All share admin navigation with overview, health, users, media jobs and backups. Settings have labels, defaults, help, units and timing; list textareas use one entry per line, optional strings clear by empty input, byte limits use exact decimal MiB without rounding. Server-rendered category anchors and preview/confirm/discard work without JavaScript. JavaScript only adds search. Existing routes, IDs and field names are preserved. + +`media.nsfw_blur_enabled` uses the existing runtime atomic and applies immediately. Backup policy is read for manual operations and by the scheduler on its next 60-second check. Every other active setting is startup-only; saved differences show the current running value and restart-pending status. Reserved fields explicitly describe their lack of runtime effect. `server.public_url` is only shown by the startup dashboard; trusted proxies are unused (forwarded headers are ignored); `posts.allow_hashtags` does not gate current hashtag extraction. These behaviors are preserved rather than silently changed during an admin upgrade. + +## Complete TOML inventory + +All entries below live in `settings.toml` under their canonical section/key. None has a value override from environment/CLI. “Optional” means omitted fields/tables are supported through serde defaults; all other fields remain required. None contains a secret. Numeric values retain Rust type ranges unless a smaller bound is listed. Signed rate limits <= 0 block the relevant action. Empty lists disable the corresponding upload formats or proxy trust list. Default byte values are bytes; controls show MiB. + +| Canonical key | Rust type | Default | Optional | Consumer | Prior admin | Current editing / timing / bounds | +|---|---|---|---|---|---|---| +| `site.name` | `String` | `"RustPost"` | Yes | compression, server, db | Yes | Restart required; Shown in page titles, the header, and the footer. Empty hides the display name. | +| `server.host` | `String` | `"127.0.0.1"` | No | cli | No | Restart required; IP address to listen on. A restart can disconnect this console. | +| `server.port` | `u16` | `8080` | No | tor, cli | No | Restart required; TCP port, 0–65535. Port 0 asks the OS to choose a port. Restart required. | +| `server.public_url` | `String` | `""` | No | terminal startup/status dashboard only; generated links remain relative | No | Restart required; Optional HTTP(S) URL shown in the startup dashboard. RustPost currently generates relative links regardless of this value. | +| `server.cookie_secure` | `bool` | `false` | No | server | No | Restart required; Enable only when visitors always use HTTPS. | +| `server.trusted_proxy_cidrs` | `Vec` | `["127.0.0.1/32", "::1/128"]` | No | Reserved; client IP uses direct ConnectInfo, ignores forwarded headers | No | Restart required; One IP/CIDR per line. Reserved: RustPost currently ignores forwarded IP headers and uses the direct connection address. | +| `accounts.registration_enabled` | `bool` | `true` | No | server | Yes | Restart required; Allow this feature. | +| `accounts.registration_captcha_enabled` | `bool` | `false` | Yes | server | Yes | Restart required; Allow this feature. | +| `accounts.anonymous_mode_enabled` | `bool` | `false` | No | social, server | Yes | Restart required; Allow this feature. | +| `accounts.min_password_length` | `usize` | `10` | No | server, validation | Yes | Restart required; Characters. Recommended default is 10; 0 permits empty passwords. | +| `accounts.max_username_len` | `usize` | `32` | No | portability, auth, identity | Yes | Restart required; Characters. | +| `accounts.max_display_name_len` | `usize` | `64` | No | server, validation | Yes | Restart required; Characters. | +| `accounts.max_bio_len` | `usize` | `240` | No | server, validation | Yes | Restart required; Characters. | +| `accounts.allow_profile_banners` | `bool` | `true` | No | server | Yes | Restart required; Allow this feature. | +| `accounts.allow_profile_pictures` | `bool` | `true` | No | server | Yes | Restart required; Allow this feature. | +| `accounts.deletion_grace_period_days` | `u64` | `30` | Yes | server, account | No | Restart required; Days, 0–3650. Set 0 for immediate deletion. | +| `accounts.max_archive_upload_bytes` | `u64` | `314572800` | Yes | portability, server | No | Restart required; MiB. Applies to imports and exports; 0.0625–2048 MiB. | +| `accounts.max_archive_expanded_bytes` | `u64` | `1073741824` | Yes | portability, server | No | Restart required; MiB of decompressed media; 0.0625–16384 MiB. | +| `accounts.max_archive_entries` | `usize` | `10000` | Yes | portability | No | Restart required; Tar entries per import, 1–100000. | +| `posts.max_text_chars` | `usize` | `280` | No | portability, social, server | Yes | Restart required; Characters. | +| `posts.post_edit_window_seconds` | `u64` | `15` | Yes | social, server | Yes | Restart required; Seconds, 0–300. Set 0 to disable editing. | +| `posts.max_images_per_post` | `usize` | `4` | No | social, server | Yes | Restart required; Attachments per post. | +| `posts.max_videos_per_post` | `usize` | `1` | No | social, server | Yes | Restart required; Attachments per post. | +| `posts.max_media_per_post` | `usize` | `4` | No | social, server | Yes | Restart required; Attachments per post. | +| `posts.allow_reposts` | `bool` | `true` | No | posts runtime | Yes | Restart required; Allow this feature. | +| `posts.allow_replies` | `bool` | `true` | No | posts runtime | Yes | Restart required; Allow this feature. | +| `posts.allow_likes` | `bool` | `true` | No | posts runtime | Yes | Restart required; Allow this feature. | +| `posts.allow_bookmarks` | `bool` | `true` | No | posts runtime | Yes | Restart required; Allow this feature. | +| `posts.allow_hashtags` | `bool` | `true` | No | Reserved; hashtag extraction is unconditional | Yes | Restart required; Reserved: hashtag extraction currently operates regardless of this flag. | +| `posts.allow_mentions` | `bool` | `true` | No | social | Yes | Restart required; Allow this feature. | +| `media.ffmpeg_path` | `String` | `"ffmpeg"` | No | ffmpeg | No | Deployment executable: arbitrary process selection with server privileges. | +| `media.convert_images_to_webp` | `bool` | `true` | No | media | No | Restart required; Convert accepted images using the existing image processor. | +| `media.convert_videos_to_webm` | `bool` | `true` | No | media runtime | No | Restart required; Convert accepted videos using FFmpeg. | +| `media.keep_original_uploads` | `bool` | `false` | No | media | No | Restart required; Retain original uploads in addition to converted media. | +| `media.nsfw_blur_enabled` | `bool` | `true` | Yes | portability, auth, server | Yes | Immediate; Blur flagged media unless a user disables their own blur setting. | +| `media.max_image_size` | `u64` | `52428800` | No | server | Yes | Restart required; MiB per image (1 MiB = 1,048,576 bytes). Decimal values are accepted. | +| `media.max_video_size` | `u64` | `157286400` | No | media, server | Yes | Restart required; MiB per video (1 MiB = 1,048,576 bytes). Decimal values are accepted. | +| `media.generate_video_thumbnails` | `bool` | `true` | No | media runtime | No | Restart required; Use FFmpeg to extract video previews. | +| `media.allowed_image_mime_types` | `Vec` | `["image/jpeg", "image/png", "image/gif", "image/webp"]` | No | portability | No | Restart required; One MIME type per line, for example image/jpeg. Empty disables image uploads. | +| `media.allowed_video_mime_types` | `Vec` | `["video/mp4", "video/webm", "video/quicktime"]` | No | portability | No | Restart required; One MIME type per line, for example video/mp4. Empty disables video uploads. | +| `media.webp_quality` | `u8` | `82` | No | ffmpeg | No | Restart required; Image quality, 0–100. Higher values produce larger files. | +| `media.vp9_crf` | `u8` | `32` | No | ffmpeg | No | Restart required; VP9 constant quality, 0–63. Lower values produce larger files. | +| `media.vp9_deadline` | `String` | `"good"` | No | ffmpeg | No | Restart required; Best prioritizes quality; good balances speed; realtime prioritizes speed. | +| `tor.enabled` | `bool` | `false` | No | server, backup, tor, cli | No | Restart required; Start the embedded Arti onion service on restart. | +| `tor.tor_only` | `bool` | `false` | No | tor, cli | No | Restart required; Disable the public HTTP listener. Requires the onion service; ensure onion access before restarting. | +| `tor.data_dir` | `String` | `"tor"` | No | runtime, backup, cli | No | Private-key storage relocation needs a filesystem migration; changing it alone can lose onion identity. | +| `tor.onion_service_name` | `String` | `"microblog"` | No | tor | No | Restart required; Validated local name. Changing it selects a different onion identity on restart; it does not migrate keys. | +| `tor.display_onion_address` | `String` | `""` | Yes | tor runtime | No | Restart required; Optional 56-character v3 onion hostname. Empty uses the running onion service address. | +| `tor.bootstrap_timeout_secs` | `u64` | `120` | No | tor | No | Restart required; Seconds allowed for Arti bootstrap. | +| `tor.max_concurrent_streams` | `usize` | `512` | No | tor | No | Restart required; Streams per circuit; must fit in a 32-bit unsigned integer. | +| `tor.include_tor_keys_in_backups_by_default` | `bool` | `false` | No | config validation forbids true; manual backups use explicit consent | No | Must remain false by validation; manual private-key inclusion requires explicit consent. | +| `moderation.posts_per_minute` | `i64` | `5` | No | server | No | Restart required; Posts per account per minute; values of 0 or less block posting. | +| `moderation.replies_per_minute` | `i64` | `10` | No | server | No | Restart required; Replies per account per minute; values of 0 or less block replies. | +| `moderation.reposts_per_minute` | `i64` | `10` | No | server | No | Restart required; Reposts per account per minute; values of 0 or less block reposts. | +| `moderation.account_creations_per_ip_per_day` | `i64` | `3` | No | server | No | Restart required; Registrations per client IP per day; values of 0 or less block registration. | +| `moderation.failed_login_attempts_per_15m` | `i64` | `10` | No | server | No | Restart required; Failed logins per client IP per 15 minutes; values of 0 or less block login. | +| `moderation.anonymous_posts_per_ip_per_hour` | `i64` | `10` | No | server | No | Restart required; Anonymous posts per client IP per hour; values of 0 or less block posting. | +| `admin.create_admin_on_first_boot` | `bool` | `true` | No | cli: first-admin interactive bootstrap | No | Restart required; Prompt for an administrator at startup only when no administrator exists. | +| `backup.enabled` | `bool` | `true` | Yes | server, backup, tor, cli | Yes | Live / next scheduler check; Allow manual backups and scheduled backups. | +| `backup.backup_dir` | `String` | `"backups"` | Yes | runtime paths + backup scheduler/restore | No | Backup storage relocation needs a filesystem migration; startup paths are immutable. | +| `backup.automatic_enabled` | `bool` | `false` | Yes | server, backup | Yes | Live / next scheduler check; Run automatic backups using the configured interval. | +| `backup.automatic_interval_minutes` | `u64` | `1440` | Yes | server, backup | Yes | Live / next scheduler check; Minutes between backups; minimum 1. Scheduler checks once per minute. | +| `backup.retention_keep_last` | `usize` | `10` | Yes | server, backup | Yes | Live / next scheduler check; Keep the newest 1–10000 automatic backups. Manual backups are unaffected. | +| `backup.retention_max_age_days` | `u64` | `30` | Yes | server, backup | Yes | Live / next scheduler check; Days, 0–3650. Set 0 to disable age-based pruning. | +| `backup.automatic_include_tor_keys` | `bool` | `false` | Yes | server, backup | Yes | Live / next scheduler check; Sensitive: backups will contain private onion identity keys. Protect backup files. | + +## Other persisted site options + +| Option | Source / default | Validation / runtime | Admin / timing | +|---|---|---|---| +| announcement | SQLite instance_settings / empty | <=280 characters, controls limited to newline/tab; layout | Existing overview, live | +| announcement_enabled | SQLite / false | Canonical stored `1`; empty text disables | Existing overview, live | +| maintenance_mode | SQLite / false | Canonical `1`; maintenance middleware and posting controls | Existing overview, live | +| maintenance_message | SQLite / empty | <=280 characters, validated controls; default maintenance copy when empty | Existing overview, live | +| favicon | assets/favicon.{ico,png,svg} / built-in | <=256 KiB, signature/format and passive SVG validation | Existing overview upload/remove, live | + +## Constants and unexposed subsystems + +Repository-wide searches covered TOML, serde/defaults, CLI, environment, `DEFAULT_`, `MAX_`, `MIN_`, durations, paths, constructor arguments, feature flags and runtime reads. There is no email/SMTP subsystem, external federation protocol, configurable DB DSN/credentials, TLS/master-key configuration, or configurable cache layer to expose. + +Fixed limits are implementation/security invariants, not operator settings: 100-million-pixel image decoding ceiling, media sniff buffers/thumbnail geometry; favicon parser/size limits; CAPTCHA alphabet/length/10-minute expiry and bounded store; archive manifest/document/count/text/hash/path-validation safety ceilings; ZIP/tar format versions; username reserved routes and released-username tombstone caps; SQL schema versions/required indexes/triggers; search/pagination/suggestion caps; backup lock names and once-per-minute polling; temp cleanup scan caps and active-operation guards; YouTube endpoint/ID rules, response ceilings and subsecond/network deadlines; compression threshold/buffering limits; CSRF token history; hashing/Argon2 policy and session lifecycle; scheduler cadence, temp-file age, seven-day session cleanup and two-hour rate-event retention. None was previously configurable through an admin-oriented source. Exposing parser guardrails or internal batching knobs would broaden the product/config surface and weaken safety without a corresponding existing configuration contract. This upgrade does not invent new keys for them. + +Runtime directory names, SQLite busy timeouts/WAL settings and worker sizes are fixed implementation choices; relocating databases and private-key storage requires an offline filesystem operation, not a field edit. Cargo features and optional Tor dependencies are build-time choices. Demo credentials are confined to the explicit demo-seed CLI operation, never exposed as settings. User profile/theme/protection/NSFW preferences, import/export metadata and moderation database state are account/operational data, not instance configuration; their existing dedicated UI remains authoritative. + +## Verification + +Rust coverage includes complete model classification, defaults, enum parsing, valid/invalid limits and URLs/CIDRs/MIME lists, empty optional values, exact byte round trips, multi-line/quoted TOML persistence, missing-field insertion, unrelated value preservation, file-write failure and restart classification. HTTP coverage verifies admin authorization, CSRF, malformed/duplicate/incomplete submissions, stale revisions, preserved submissions, preview/discard/confirm and unchanged running settings. + +Run `cargo fmt --all --check`, `cargo check --workspace --all-targets --all-features`, strict Clippy and `cargo test --workspace --all-features`. Browser suite: first `cargo build --workspace --all-features --bin rustpost-cli`, then `npx playwright test -c tests/playwright/configuration-admin.config.mjs` (uses the existing @playwright/test installation). It self-hosts disposable runtimes in Chromium, WebKit and Chromium with JavaScript disabled, plus a Firefox no-JS project, checks every category/type, error retention, save/reload/restart, unknown-secret nondisclosure, category navigation, search, keyboard focus/label/help associations, controls and page overflow at 320/375/768/1024/1440/1920px. Screenshots/traces stay in ignored `output/playwright`. Test fixture extensions contain dummy values only. + +Validation on this host: Chromium, Chromium no-JS and WebKit pass, including all six responsive widths and shared navigation across all six administration pages. Firefox's bundled Nightly fails before test execution with a macOS sandbox-extension/framebuffer error; the Firefox project remains available for compatible hosts. Accessibility checks assert label/help associations, field error associations, status roles, visible keyboard focus and tab order; this is not a full assistive-technology certification. No external/live Tor network tests were needed for this configuration/editor change. diff --git a/docs/docker.md b/docs/docker.md index 1ef34f6..9db6f3f 100644 --- a/docs/docker.md +++ b/docs/docker.md @@ -106,3 +106,7 @@ The `latest` tag currently points to v1.0.0. A version tag makes the release you | Permission errors under `/data` | For bind mounts, confirm the dedicated folder is writable by UID/GID `10001`. | | You cannot log in as administrator | Run the interactive administrator creation command from the README if no administrator exists. See the [CLI reference](operator-guide.md#cli-reference) for password reset commands. | | Settings changes have no effect | Validate with `rustpost-cli --data-dir /data check` inside the container, then restart it. | + +## Admin update checks + +**Admin → Software updates** can check official stable releases. This image declares container-managed deployment, so the panel does not replace binaries or control the host. Back up and verify the persistent `/data` state, stop the container, update its image through Docker/your deployment tool, retain `/data`, and verify application/database health. On failure, stop the new image and restore the pre-upgrade database/configuration before restarting the old image. Never mount a Docker socket into RustPost. See [the software update guide](software-updates.md). diff --git a/docs/operator-guide.md b/docs/operator-guide.md index f405a38..763c544 100644 --- a/docs/operator-guide.md +++ b/docs/operator-guide.md @@ -213,7 +213,7 @@ Restore path validation rejects: absolute paths, traversal sequences, symlinks/h **Live/local Tor smoke validation:** - Start dual mode with a fresh explicit `--data-dir`, then confirm Arti bootstrap and onion descriptor publication in the logs. -- Valid public local smoke endpoints are `/`, `/home`, `/login`, and `/register`. RustPost does not implement `/healthz` or `/readyz`; use the startup/status output and authenticated `/admin/health` page for operational status. +- Valid public local smoke endpoints are `/`, `/home`, `/login`, and `/register`. RustPost does not implement `/healthz` or `/readyz`; managed updates use the loopback-only `/internal/update-health` endpoint. For ordinary operation use the startup/status output and authenticated `/admin/health` page for operational status. - The active v3 onion hostname must contain 56 characters followed by `.onion`, and the same address must appear in startup/status output and the public Tor pill. - Confirm the printed loopback Arti forwarder target serves the same local page as the clearnet listener. - Onion-routed validation requires a reachable SOCKS proxy. Prefer Tor Browser at `127.0.0.1:9150`, then system Tor at `127.0.0.1:9050`: @@ -375,3 +375,7 @@ The format, release build, strict Clippy, and test commands must pass. `cargo au - **UI polish** — server-rendered HTML/CSS covers the core flows, but final visual design and broader accessibility review are still future work. --- + +## Software updates + +Use **Admin → Software updates** for stable GitHub release checks. A configured native Linux updater supports signed installation with mandatory exact SQLite/config backups, atomic activation, bounded health checks and automatic binary/database rollback. Containers remain deployment-managed. See [software-updates.md](software-updates.md) for administrator behavior, provisioning, retention, troubleshooting and signing/release requirements. diff --git a/docs/rust-1.99-upgrade.md b/docs/rust-1.99-upgrade.md new file mode 100644 index 0000000..c92afb6 --- /dev/null +++ b/docs/rust-1.99-upgrade.md @@ -0,0 +1,39 @@ +# Rust 1.99 compiler upgrade — 2026-10-01 + +The new rust-toolchain.toml selects exact Rust 1.99.0 with rustfmt and Clippy. +CI, releases and the container now build with 1.99.0 instead of 1.91.0. +Cargo's Rust 1.91 MSRV is retained as a compatibility floor, independently of +the build compiler. Edition 2024, application version 1.0.0, dependencies and +Cargo.lock are unchanged. + +CI explicitly selects latest stable for strict Clippy and full tests, and +1.91.0 for the MSRV check. Release compilers remain reproducibly pinned and +advance after the full validation gates pass. There is no automatic background +updater or floating compiler in release builds. All build/test resolution is locked. + +The official Rust 1.99 Bookworm Docker tags were unavailable on release day. +The Dockerfile bootstraps from official rust:1.98.1-bookworm, then installs and +selects exact Rust 1.99.0 using official rustup. The final build uses 1.99.0; +Debian 12, ffmpeg and the unprivileged runtime account remain unchanged. + +Strict Clippy findings were fixed by sharing the identical HEAD response +construction after its match and using diagnostic equality/length assertions +in the existing tests. No test or lint gate was removed or suppressed. + +## Validation + +All checks passed locally on macOS Apple Silicon: + +- cargo +1.99.0 fmt --all --check +- cargo +1.99.0 check --locked --workspace --all-features +- cargo +1.99.0 clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +- cargo +1.99.0 test --locked --workspace --all-features: 530 passed. +- python3 tools/test-release-tools.py: four signed-update packaging/rejection tests passed. +- cargo +1.99.0 build --locked --release --workspace --all-features --bins: CLI and updater built. +- cargo +1.91.0 check --locked --workspace --all-targets --all-features +- Updated multi-stage Docker build on Linux ARM64, version probe, unprivileged startup and HTTP home-page smoke check. +- Workflow YAML parsing and git diff --check. + +Other existing native CI/release targets are retained; no new remote CI, +Windows host or physical hardware run is claimed. The work is local only: +no push, tag, published release, signing credentials or production deployment. diff --git a/docs/software-updates.md b/docs/software-updates.md new file mode 100644 index 0000000..180b811 --- /dev/null +++ b/docs/software-updates.md @@ -0,0 +1,102 @@ +# Administrator software updates + +Open **Admin → Software updates** (also linked as **Updates** in the admin navigation). The installed version always comes from the running executable. **Check for updates** queries the public API of `csd113/RustPost` on GitHub: published stable releases only, ordered by semantic version. No token is required. Network/API failures are shown without affecting ordinary operation. Availability checks are manual; installation is never unattended. + +A managed native Linux instance displays the latest compatible version (falling back past newer incompatible releases), date, bounded escaped plain-text release notes, download size, signature/compatibility result, install protections, backup history and last transaction result. Install requires the administrator's current password, valid CSRF token and a one-use updater approval. Another check replaces the old approval; concurrent or replayed installations are refused. RustPost has one administrator role, and suspended, forced-password-reset and deletion-pending accounts retain their existing restrictions. + +Click **Install update** after reviewing the release. User mutations pause, the updater checks space/paths/schema, downloads and verifies the release, stages its executable, and verifies a live database/configuration backup before stopping the service. It then refreshes the backup after shutdown to capture all acknowledged writes, atomically activates the staged release, and starts it. The new executable performs its own transactional startup migrations. Health checks must confirm the expected version, schema, DB access and writable persistent directories before success is recorded and writes resume. + +Expect a brief outage, plus any startup/migration time. JavaScript progressively polls the authenticated status endpoint and reconnects after restart. Without JavaScript, reload **Admin → Software updates** after the service returns; the final result survives both application and updater restarts. A rollback result means the previous executable **and exact pre-upgrade database/configuration** were restored before the previous service restarted. User media, assets, Tor keys and instance identity are preserved in place. Do not allow external jobs to write to the database during an update. + +## Backups and retention + +Managed update snapshots live in `/var/lib/rustpost-updater/backups//` by default, independently of ordinary backup settings. Every snapshot contains `database.sqlite3`, `settings.toml` and `metadata.json`: timestamp, old/new version and schema, sizes, SHA-256 hashes, verification state and `pre_upgrade` reason. SQLite `VACUUM INTO` captures WAL contents consistently; integrity and foreign-key checks verify the exact schema snapshot without migrating it. Configuration backup is mandatory even when ordinary backups are disabled. The final backup is synced and published before activation. + +Both Updates and Backups show pre-upgrade backup history. The updater configuration defaults to five backups (allowed range 2–20). Pruning runs after verified success, safe preparation failure or a healthy rollback, reconciles snapshots published before a journal interruption, keeps the newest configured count, and always protects the current transaction's rollback snapshot; unresolved rollback failures prohibit new updates and retain their snapshots. The previous executable is retained; older program versions are removed only after a successful later upgrade. Ordinary manual/full-site archives retain their existing policies. The panel does not add an arbitrary snapshot restore endpoint. + +## Troubleshooting + +- **Updater unavailable:** check `systemctl status rustpost-updater` and `journalctl -u rustpost-updater`. Verify socket permissions and the configured web UID. Managed application startup/mutations fail closed while updater state cannot be verified. +- **Unverified/incompatible release:** provision the trusted signing public key and confirm that the official release contains the signed manifest for your target. Older releases without update artifacts are check-only. Do not bypass signature checks. +- **Update stopped safely:** old program and DB were not activated/migrated. Resolve the reported preflight, network, space, checksum or backup issue, then check again. +- **Rolled back:** inspect updater/service logs to identify the new version's startup, migration or health failure before another attempt. The retained snapshot is the pre-upgrade state. +- **Operator intervention required:** keep RustPost stopped. Preserve the journal and protected backup. Repair service/permissions/storage or the verified backup, then have an operator restore the old DB/config and pointer together. Do not simply launch the old executable against an upgraded database. There is deliberately no web-accessible force-reset/downgrade command. +- **Crash/reboot during upgrade:** the updater's durable journal and OS locks determine recovery. Application startup checks updater readiness before migrations; a daemon boot barrier permits an interrupted transaction to start only through its controlled recovery step. HTTP mutations, automatic backups and destructive background maintenance remain paused until the terminal journal is durable and the worker releases admission. Release checks do not pause ordinary writes. Preparation interruptions resume the unchanged old DB; interruptions after activation begins restore the old snapshot first. Never delete active update state to clear a lock. + +Containers are check-only. Do not mount a Docker socket or grant the web process host permissions. Create and verify an ordinary backup, stop the container, replace the image through your deployment system, reuse the existing persistent volume and verify application/database health. If it fails, stop the new container and restore the pre-upgrade database/config backup before restarting the old image. See [Docker deployment](docker.md). macOS, Windows and unmanaged direct CLI deployments display availability and explain deployment-managed installation. + +# Native managed Linux deployment + +The supplied integration targets Linux GNU x86_64 and aarch64 with systemd and polkit. Releases built on Ubuntu 24.04 require a compatible GNU libc/system library baseline; do not assume older distributions are supported. Use a clearnet loopback listener for machine health (`127.0.0.1:8080` by default), including dual Tor mode. Tor-only deployments lack that stable listener and cannot use this integration. + +The HTTP account has **no sudo, systemd authority, shell installer, program-file write permission or Docker socket**. A separate unprivileged `rustpost-updater` account owns `/opt/rustpost` and `/var/lib/rustpost-updater`. It shares only the persistent data group with the web account. The systemd service sandbox restricts writes to the managed program, data, update-state and socket directories, with no capabilities and no new privileges. The polkit rule allows only `start` and `stop` of `rustpost.service` for that account. Calls use a fixed absolute systemctl executable, fixed unit and bounded wait; no shell or HTTP-supplied command arguments are executed. + +The Unix socket `/run/rustpost-updater/control.sock` is mode `0660`, owned by the updater with group `rustpost`; the parent is `0750`. Peer credentials must match the configured web UID. IPC accepts only status, stable check, install using a persisted one-use approval/positive admin ID, and startup-readiness operations. Paths, URLs, service names and command arguments cannot be submitted. Treat compromise of the web account as authorization to request official signed updates only. Protect the updater account and root-controlled trust/configuration separately. + +## One-time setup or adoption of an existing install + +These are operator provisioning steps, performed once on the intended Linux host. No normal admin-panel update requires SSH. First take a verified full-site backup and stop the old instance. Preserve its explicit data directory and settings path; never rely on executable-relative defaults in a versioned install. Review the supplied files under `deploy/systemd/` before installing them. + +1. Create system accounts `rustpost` and `rustpost-updater`; use a shared `rustpost` group. The web UID must be nonzero and different from the updater UID. Existing web accounts can be kept if all paths/unit names and group ownership match. Add the updater to the persistent-data group, never the web account to a privileged group. +2. Create `/opt/rustpost/versions//`, install the current compatible `rustpost-cli` there, and make `/opt/rustpost/current` a **relative** symlink to `versions/`. The managed installation root, `versions/` and version directories must be owned by the updater, with directories `0755` and executable `0755`, no group/other write access. Executables may be root-owned and read-only to the updater; writable staging/retention parents must belong to the updater. Use the newly built implementation as the initial version: older RustPost binaries lack the startup gate/health endpoint and must be adopted offline first. +3. Install the matching `rustpost-updater` binary as `/usr/local/libexec/rustpost-updater`, root-owned `0755`. The helper is maintained by the operator separately; application updates never replace the helper or trust key. +4. Keep persistent state at `/var/lib/rustpost`, owned by web user and group `rustpost`. Set directories `0770`, database/config/sidecars and an existing `tmp/update-coordination.lock` `0660` where group access is needed, and use unit `UMask=0007`. Do not weaken private Tor key file permissions. Managed runtime setup keeps sensitive directory group access for the trusted updater, while unmanaged deployments retain their original `0700` modes. If adopting another directory, adjust both unit paths and updater configuration; all privileged paths must be absolute and have no symlink ancestors. Program/state/trust/config parents must be owned by root or their trusted leaf owner, with no web ownership or group/other write permission. Root-owned sticky shared temporary parents cannot permit another UID to replace protected children. +5. Create `/var/lib/rustpost-updater` and its `backups/` subdirectory, owned by the updater and mode `0700`. Do not put update state under the web-owned data directory. Create `/etc/rustpost-updater`, root-owned `0755`, and copy `deploy/systemd/updater.toml` there as root-owned `0644`. Set `web_uid` to the output of `id -u rustpost`, verify the stable data/settings paths and set the health port to the actual loopback port. Configuration may use an external settings path, but it must match the web service's `--config` and sandbox writable paths. +6. Provision `/etc/rustpost-updater/public-key.hex` from the maintainer's authenticated Ed25519 public key (32 raw bytes, 64 hex digits, newline allowed), root-owned `0644`. Never obtain a replacement trust key from the release being installed. The daemon refuses missing, invalid, web-writable or non-root-owned keys/configuration. +7. Copy both `.service` files into `/etc/systemd/system/` and the supplied rule into `/etc/polkit-1/rules.d/49-rustpost-updater.rules`, all root-owned `0644`. Reload systemd, enable/start the updater, then enable/start RustPost. Do not add an `After=rustpost.service` dependency to the updater: it must recover while RustPost is stopped. Ensure `/usr/bin/pkcheck` and polkit are installed; preflight verifies both fixed service permissions before shutdown. Validate the polkit rule on your distribution and the installed units with `systemd-analyze verify`. +8. Sign in locally, open Updates, check releases, and verify that only an authenticated admin can reach actions. Perform successful and deliberately failed upgrades in a disposable Linux staging instance before production adoption. No test should stop an existing host service. + +Sample ownership checks: + +```sh +id rustpost +id rustpost-updater +namei -l /opt/rustpost/current/rustpost-cli +namei -l /var/lib/rustpost-updater/backups +systemctl status rustpost-updater rustpost +journalctl -u rustpost-updater -u rustpost +``` + +Keep the fixed `rustpost.service` name and socket path. The web process startup gate and mutation pause depend on `RUSTPOST_MANAGED=1` from the supplied unit. Protect unit environment, CLI arguments, updater config and installation files from web writes. Block `/internal/update-health` in an external reverse proxy; the updater calls the loopback listener directly. Its JSON contains no paths or secrets. + +# Maintainer release contract + +The existing macOS/Windows/Linux manual-download archives and checksums are preserved. Stable native Linux releases additionally publish one `rustpost-update-.tar.gz` containing **only** a regular `rustpost-cli` file, a `rustpost-update-.json` manifest, and a raw 64-byte `.json.sig` Ed25519 signature. Target names are exactly `x86_64-unknown-linux-gnu` and `aarch64-unknown-linux-gnu`. + +The signed manifest binds format, stable version, GitHub release ID, target, artifact filename/size/SHA-256, executable size/SHA-256, target schema, minimum source schema and minimum updater version. The updater checks HTTPS source/redirect allowlists, signature, all identities/limits, archive checksum and layout, executable checksum and ELF architecture. Unknown paths, links, entries, targets and mismatches are rejected. New binaries execute only under the unprivileged web service, never as the updater. + +`rustpost-cli --update-info` prints compatibility/version metadata without opening runtime storage. `tools/package-update.py` checks binary metadata against the release tag/target before building the dedicated archive. `tools/sign-updates.py` validates both Linux packages, hashes, executable architecture and schema metadata, then signs exact JSON bytes and verifies its signatures. The release workflow collects **all four** manual-download target packages first, verifies checksums, stages a GitHub draft, signs using its release ID and publishes only after all required assets pass. A published release is immutable to retries; failed signing leaves an unpublished draft. Prerelease tags retain the manual-download archives and publish as GitHub prereleases without auto-update artifacts or stable/latest promotion; signing secrets are mandatory for stable publication. Containers use tag-derived versions and retain non-root/data-volume deployment. + +## Signing provisioning + +Generate an Ed25519 private key offline using OpenSSL 3: + +```sh +openssl genpkey -algorithm ED25519 -out rustpost-update-signing.pem +openssl pkey -in rustpost-update-signing.pem -pubout -outform DER -out rustpost-update-public.der +python3 -c 'from pathlib import Path; d=Path("rustpost-update-public.der").read_bytes(); assert d[:12].hex()=="302a300506032b6570032100" and len(d)==44; print(d[12:].hex())' +``` + +Set repository Actions secret **`RUSTPOST_UPDATE_SIGNING_KEY`** to the entire PEM private key. Publish the printed public-key hex through an authenticated maintainer channel and provision it into each host's root-owned trust file. Store the private key offline and in CI secrets only; never commit it. CI writes a temporary `0600` key and removes it on exit. Missing secrets fail the release job; there is no unsigned installation fallback. Key rotation requires an explicit trusted operator update of the host key. OpenSSL 3 is needed for the release scripts. + +Keep the schema metadata and actual migration support aligned. `--update-info` currently declares minimum schema 1 because the inspected migration system supports release schemas 1–4; revise this when dropping an old migration path. Keep migrations transactional, preserve persistent file formats, and do not introduce media/Tor mutations during startup: automatic rollback restores DB/config, not arbitrary side effects. New updater protocol requirements must increase `minimum_updater`, with an operator helper upgrade before installation. Health must report expected schema only after structural validation; never use process existence alone. + +Tests use ephemeral signatures, localhost artifact servers, temporary SQLite WAL databases and simulated services. They exercise download/verify/backup/migrate/activate/restart/health success, migration/start/health rollback, config/data restoration, checksum/layout/architecture failures, interrupted journals, OS locks, retention and protected backups. The production source allowlist is never loosened for test URLs. Route/browser tests cover admin authorization, CSRF, reauthentication, GET refusal, safe external notes, no-JS behavior and persisted result rendering. Local browser outage fixtures exercise an install response disappearing before redirect, progress/backup display, automatic reconnect to a GET result page, and both success and rollback; no-JS clients refresh the persisted result. Manual local review used the same isolated UI fixtures; actual database/binary recovery is asserted by transaction tests. + +Validation: + +```sh +cargo fmt --all --check +cargo check --workspace --all-targets --all-features +cargo clippy --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo +cargo test --workspace --all-features +cargo build --release --bins +node node_modules/@playwright/test/cli.js test -c tests/playwright/software-updates.config.mjs +node node_modules/@playwright/test/cli.js test -c tests/playwright/configuration-admin.config.mjs +``` + +Portable tests simulate service management. A disposable Linux/systemd/polkit deployment must additionally validate actual UID/group permissions, service stop/start, reboot during activation, and successful/failed signed releases before production rollout. macOS development cannot exercise Linux systemd itself. Do not point tests at a real production installation. + +For the macOS 27 Playwright Firefox app-data collision ([upstream issue 42768](https://github.com/microsoft/playwright/issues/42768)), browser configurations accept `RUSTPOST_FIREFOX_EXECUTABLE` pointing to a disposable same-revision launcher with an isolated app identity. Stock browsers remain the default; do not disable browser sandboxing or change an installed personal Firefox profile. The local validation used this override for Firefox and reran the complete suites. + +SQLite's [supported snapshot mechanisms](https://www.sqlite.org/backup.html) and GitHub's [release asset API](https://docs.github.com/en/rest/releases/assets) underpin the backup and source/integrity contract. diff --git a/rust-toolchain.toml b/rust-toolchain.toml new file mode 100644 index 0000000..eb39547 --- /dev/null +++ b/rust-toolchain.toml @@ -0,0 +1,4 @@ +[toolchain] +channel = "1.99.0" +profile = "minimal" +components = ["clippy", "rustfmt"] diff --git a/src/account.rs b/src/account.rs index 78f9c7c..517edb1 100644 --- a/src/account.rs +++ b/src/account.rs @@ -1250,7 +1250,7 @@ mod tests { let first = request_deletion(&pool, &settings, alice) .await .expect("request"); - assert!(!first.requested_at.is_empty()); + assert_ne!(first.requested_at.len(), 0); assert!(first.scheduled_at > first.requested_at); let second = request_deletion(&pool, &settings, alice) .await @@ -1322,7 +1322,7 @@ mod tests { let re_requested = request_deletion(&pool, &settings, alice) .await .expect("re-request"); - assert!(!re_requested.requested_at.is_empty()); + assert_ne!(re_requested.requested_at.len(), 0); } #[tokio::test] @@ -1585,7 +1585,7 @@ mod tests { ); assert!(!user_exists(&pool, alice).await); assert!(!media_path.exists()); - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); let counts: (i64, i64) = pool .call(move |conn| { Ok(( @@ -1745,7 +1745,7 @@ mod tests { assert_eq!(counts, (0, 0, 0), "deletion must remove every owned row"); assert!(!media_path.exists()); } - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); assert_eq!( finalize_due_deletions(&pool, &paths) .await @@ -1806,7 +1806,7 @@ mod tests { .await .expect("counts"); assert_eq!(counts, (1, 1)); - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); let fixed_path = paths.uploads_images.join("fixed.webp"); fs::write(&fixed_path, b"image").expect("fixed media file"); @@ -1829,7 +1829,7 @@ mod tests { ); assert!(!user_exists(&pool, alice).await); assert!(!fixed_path.exists()); - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); } #[tokio::test] @@ -1867,7 +1867,7 @@ mod tests { 1 ); assert!(!blocked_path.exists()); - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); assert_eq!( recover_pending_media_deletions(&paths) .await @@ -2026,7 +2026,7 @@ mod tests { recovered.expect("concurrent recovery"); assert!(!user_exists(&pool, bob).await); assert!(!bob_media.exists()); - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); assert_eq!( recover_pending_media_deletions(&paths) .await @@ -2055,7 +2055,7 @@ mod tests { ); assert!(!user_exists(&pool, alice).await); assert!(!media_path.exists()); - assert!(pending_media_journals(&paths).is_empty()); + assert_eq!(pending_media_journals(&paths).len(), 0); } #[tokio::test] diff --git a/src/admin.rs b/src/admin.rs index 831a234..f088769 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1,4 +1,6 @@ -use std::fs::{self, File}; +use std::fs; +#[cfg(unix)] +use std::fs::File; use std::io::Write as _; use std::path::Path; @@ -7,103 +9,16 @@ use rusqlite::{Row, params, params_from_iter}; use serde::Deserialize; use crate::auth; -use crate::config::{BackupSettings, MAX_POST_EDIT_WINDOW_SECONDS, Settings}; +use crate::config::{BackupSettings, Settings}; use crate::db::SqlitePool; +#[cfg(test)] const MIB: u64 = 1024 * 1024; -#[derive(Debug, Clone, Deserialize)] -pub struct DeepSettingsForm { - pub csrf: String, - pub intent: Option, - pub site_name: String, - pub max_text_chars: String, - pub post_edit_window_seconds: String, - pub max_images_per_post: String, - pub max_videos_per_post: String, - pub max_media_per_post: String, - pub allow_reposts: String, - pub allow_replies: String, - pub allow_likes: String, - pub allow_bookmarks: String, - pub allow_hashtags: String, - pub allow_mentions: String, - pub registration_enabled: String, - pub registration_captcha_enabled: String, - pub anonymous_mode_enabled: String, - pub min_password_length: String, - pub max_username_len: String, - pub max_display_name_len: String, - pub max_bio_len: String, - pub allow_profile_banners: String, - pub allow_profile_pictures: String, - pub nsfw_blur_enabled: String, - pub max_image_size_mb: String, - pub max_video_size_mb: String, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeepSettingsField { - SiteName, - MaxTextChars, - PostEditWindowSeconds, - MaxImagesPerPost, - MaxVideosPerPost, - MaxMediaPerPost, - AllowReposts, - AllowReplies, - AllowLikes, - AllowBookmarks, - AllowHashtags, - AllowMentions, - RegistrationEnabled, - RegistrationCaptchaEnabled, - AnonymousModeEnabled, - MinPasswordLength, - MaxUsernameLen, - MaxDisplayNameLen, - MaxBioLen, - AllowProfileBanners, - AllowProfilePictures, - NsfwBlurEnabled, - MaxImageSizeMb, - MaxVideoSizeMb, -} - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum DeepSettingsInputKind { - Text, - Number, - Boolean, -} - -#[derive(Debug, Clone, PartialEq, Eq)] -pub struct DeepSettingsValues { - pub site_name: String, - pub max_text_chars: usize, - pub post_edit_window_seconds: u64, - pub max_images_per_post: usize, - pub max_videos_per_post: usize, - pub max_media_per_post: usize, - pub allow_reposts: bool, - pub allow_replies: bool, - pub allow_likes: bool, - pub allow_bookmarks: bool, - pub allow_hashtags: bool, - pub allow_mentions: bool, - pub registration_enabled: bool, - pub registration_captcha_enabled: bool, - pub anonymous_mode_enabled: bool, - pub min_password_length: usize, - pub max_username_len: usize, - pub max_display_name_len: usize, - pub max_bio_len: usize, - pub allow_profile_banners: bool, - pub allow_profile_pictures: bool, - pub nsfw_blur_enabled: bool, - pub max_image_size_mb: u64, - pub max_video_size_mb: u64, -} +pub use crate::config::admin_fields::{ + DeepSettingsField, DeepSettingsForm, DeepSettingsInputKind, DeepSettingsValues, + parse_deep_settings_form, +}; #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeepSettingsChange { @@ -113,16 +28,24 @@ pub struct DeepSettingsChange { } #[derive(Debug, Clone, Deserialize)] +#[serde(deny_unknown_fields)] pub struct BackupSettingsForm { pub csrf: String, + #[serde(default = "unchecked_backup")] pub enabled: String, + #[serde(default = "unchecked_backup")] pub automatic_enabled: String, pub automatic_interval_minutes: String, pub retention_keep_last: String, pub retention_max_age_days: String, + #[serde(default = "unchecked_backup")] pub automatic_include_tor_keys: String, } +fn unchecked_backup() -> String { + "false".to_owned() +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct BackupSettingsValues { pub enabled: bool, @@ -133,336 +56,6 @@ pub struct BackupSettingsValues { pub automatic_include_tor_keys: bool, } -impl DeepSettingsField { - pub const ALL: [Self; 24] = [ - Self::SiteName, - Self::MaxTextChars, - Self::PostEditWindowSeconds, - Self::MaxImagesPerPost, - Self::MaxVideosPerPost, - Self::MaxMediaPerPost, - Self::AllowReposts, - Self::AllowReplies, - Self::AllowLikes, - Self::AllowBookmarks, - Self::AllowHashtags, - Self::AllowMentions, - Self::RegistrationEnabled, - Self::RegistrationCaptchaEnabled, - Self::AnonymousModeEnabled, - Self::MinPasswordLength, - Self::MaxUsernameLen, - Self::MaxDisplayNameLen, - Self::MaxBioLen, - Self::AllowProfileBanners, - Self::AllowProfilePictures, - Self::NsfwBlurEnabled, - Self::MaxImageSizeMb, - Self::MaxVideoSizeMb, - ]; - - #[must_use] - pub const fn section(self) -> &'static str { - match self { - Self::SiteName => "Site", - Self::MaxTextChars - | Self::PostEditWindowSeconds - | Self::MaxImagesPerPost - | Self::MaxVideosPerPost - | Self::MaxMediaPerPost - | Self::AllowReposts - | Self::AllowReplies - | Self::AllowLikes - | Self::AllowBookmarks - | Self::AllowHashtags - | Self::AllowMentions => "Posts", - Self::RegistrationEnabled - | Self::RegistrationCaptchaEnabled - | Self::AnonymousModeEnabled - | Self::MinPasswordLength - | Self::MaxUsernameLen - | Self::MaxDisplayNameLen - | Self::MaxBioLen - | Self::AllowProfileBanners - | Self::AllowProfilePictures => "Accounts", - Self::NsfwBlurEnabled | Self::MaxImageSizeMb | Self::MaxVideoSizeMb => "Media", - } - } - - #[must_use] - pub const fn toml_section(self) -> &'static str { - match self { - Self::SiteName => "site", - Self::MaxTextChars - | Self::PostEditWindowSeconds - | Self::MaxImagesPerPost - | Self::MaxVideosPerPost - | Self::MaxMediaPerPost - | Self::AllowReposts - | Self::AllowReplies - | Self::AllowLikes - | Self::AllowBookmarks - | Self::AllowHashtags - | Self::AllowMentions => "posts", - Self::RegistrationEnabled - | Self::RegistrationCaptchaEnabled - | Self::AnonymousModeEnabled - | Self::MinPasswordLength - | Self::MaxUsernameLen - | Self::MaxDisplayNameLen - | Self::MaxBioLen - | Self::AllowProfileBanners - | Self::AllowProfilePictures => "accounts", - Self::NsfwBlurEnabled | Self::MaxImageSizeMb | Self::MaxVideoSizeMb => "media", - } - } - - #[must_use] - pub const fn toml_key(self) -> &'static str { - match self { - Self::SiteName => "name", - Self::MaxTextChars => "max_text_chars", - Self::PostEditWindowSeconds => "post_edit_window_seconds", - Self::MaxImagesPerPost => "max_images_per_post", - Self::MaxVideosPerPost => "max_videos_per_post", - Self::MaxMediaPerPost => "max_media_per_post", - Self::AllowReposts => "allow_reposts", - Self::AllowReplies => "allow_replies", - Self::AllowLikes => "allow_likes", - Self::AllowBookmarks => "allow_bookmarks", - Self::AllowHashtags => "allow_hashtags", - Self::AllowMentions => "allow_mentions", - Self::RegistrationEnabled => "registration_enabled", - Self::RegistrationCaptchaEnabled => "registration_captcha_enabled", - Self::AnonymousModeEnabled => "anonymous_mode_enabled", - Self::MinPasswordLength => "min_password_length", - Self::MaxUsernameLen => "max_username_len", - Self::MaxDisplayNameLen => "max_display_name_len", - Self::MaxBioLen => "max_bio_len", - Self::AllowProfileBanners => "allow_profile_banners", - Self::AllowProfilePictures => "allow_profile_pictures", - Self::NsfwBlurEnabled => "nsfw_blur_enabled", - Self::MaxImageSizeMb => "max_image_size", - Self::MaxVideoSizeMb => "max_video_size", - } - } - - #[must_use] - pub const fn form_name(self) -> &'static str { - match self { - Self::SiteName => "site_name", - Self::MaxTextChars => "max_text_chars", - Self::PostEditWindowSeconds => "post_edit_window_seconds", - Self::MaxImagesPerPost => "max_images_per_post", - Self::MaxVideosPerPost => "max_videos_per_post", - Self::MaxMediaPerPost => "max_media_per_post", - Self::AllowReposts => "allow_reposts", - Self::AllowReplies => "allow_replies", - Self::AllowLikes => "allow_likes", - Self::AllowBookmarks => "allow_bookmarks", - Self::AllowHashtags => "allow_hashtags", - Self::AllowMentions => "allow_mentions", - Self::RegistrationEnabled => "registration_enabled", - Self::RegistrationCaptchaEnabled => "registration_captcha_enabled", - Self::AnonymousModeEnabled => "anonymous_mode_enabled", - Self::MinPasswordLength => "min_password_length", - Self::MaxUsernameLen => "max_username_len", - Self::MaxDisplayNameLen => "max_display_name_len", - Self::MaxBioLen => "max_bio_len", - Self::AllowProfileBanners => "allow_profile_banners", - Self::AllowProfilePictures => "allow_profile_pictures", - Self::NsfwBlurEnabled => "nsfw_blur_enabled", - Self::MaxImageSizeMb => "max_image_size_mb", - Self::MaxVideoSizeMb => "max_video_size_mb", - } - } - - #[must_use] - pub const fn label(self) -> &'static str { - match self { - Self::SiteName => "Site name", - Self::MaxTextChars => "Maximum post text length", - Self::PostEditWindowSeconds => "Post edit window", - Self::MaxImagesPerPost => "Maximum images per post", - Self::MaxVideosPerPost => "Maximum videos per post", - Self::MaxMediaPerPost => "Maximum total media per post", - Self::AllowReposts => "Allow reposts", - Self::AllowReplies => "Allow replies", - Self::AllowLikes => "Allow likes", - Self::AllowBookmarks => "Allow bookmarks", - Self::AllowHashtags => "Allow hashtags", - Self::AllowMentions => "Allow mentions", - Self::RegistrationEnabled => "Registration enabled", - Self::RegistrationCaptchaEnabled => "Registration CAPTCHA enabled", - Self::AnonymousModeEnabled => "Anonymous posting enabled", - Self::MinPasswordLength => "Minimum password length", - Self::MaxUsernameLen => "Maximum username length", - Self::MaxDisplayNameLen => "Maximum display name length", - Self::MaxBioLen => "Maximum bio length", - Self::AllowProfileBanners => "Allow profile banners", - Self::AllowProfilePictures => "Allow profile pictures", - Self::NsfwBlurEnabled => "Blur NSFW media", - Self::MaxImageSizeMb => "Maximum image size", - Self::MaxVideoSizeMb => "Maximum video size", - } - } - - #[must_use] - pub const fn helper(self) -> Option<&'static str> { - match self { - Self::SiteName => Some("Shown in page titles, the header, and the footer."), - Self::MaxTextChars - | Self::MaxUsernameLen - | Self::MaxDisplayNameLen - | Self::MaxBioLen => Some("Characters."), - Self::PostEditWindowSeconds => { - Some("Seconds. Default is 15; valid range is 0 to 300. Set 0 to disable.") - } - Self::MaxImagesPerPost | Self::MaxVideosPerPost | Self::MaxMediaPerPost => { - Some("Attachments per post.") - } - Self::MinPasswordLength => Some("Characters. Recommended default is 10."), - Self::RegistrationCaptchaEnabled => Some("Requires a CAPTCHA on registration only."), - Self::NsfwBlurEnabled => Some( - "When true, flagged media is blurred unless a user disables their own blur setting.", - ), - Self::MaxImageSizeMb | Self::MaxVideoSizeMb => Some("MB."), - _ => None, - } - } - - #[must_use] - pub const fn input_kind(self) -> DeepSettingsInputKind { - match self { - Self::SiteName => DeepSettingsInputKind::Text, - Self::AllowReposts - | Self::AllowReplies - | Self::AllowLikes - | Self::AllowBookmarks - | Self::AllowHashtags - | Self::AllowMentions - | Self::RegistrationEnabled - | Self::RegistrationCaptchaEnabled - | Self::AnonymousModeEnabled - | Self::AllowProfileBanners - | Self::AllowProfilePictures - | Self::NsfwBlurEnabled => DeepSettingsInputKind::Boolean, - _ => DeepSettingsInputKind::Number, - } - } -} - -impl DeepSettingsValues { - #[must_use] - pub fn from_settings(settings: &Settings) -> Self { - Self { - site_name: settings.site.name.clone(), - max_text_chars: settings.posts.max_text_chars, - post_edit_window_seconds: settings.posts.post_edit_window_seconds, - max_images_per_post: settings.posts.max_images_per_post, - max_videos_per_post: settings.posts.max_videos_per_post, - max_media_per_post: settings.posts.max_media_per_post, - allow_reposts: settings.posts.allow_reposts, - allow_replies: settings.posts.allow_replies, - allow_likes: settings.posts.allow_likes, - allow_bookmarks: settings.posts.allow_bookmarks, - allow_hashtags: settings.posts.allow_hashtags, - allow_mentions: settings.posts.allow_mentions, - registration_enabled: settings.accounts.registration_enabled, - registration_captcha_enabled: settings.accounts.registration_captcha_enabled, - anonymous_mode_enabled: settings.accounts.anonymous_mode_enabled, - min_password_length: settings.accounts.min_password_length, - max_username_len: settings.accounts.max_username_len, - max_display_name_len: settings.accounts.max_display_name_len, - max_bio_len: settings.accounts.max_bio_len, - allow_profile_banners: settings.accounts.allow_profile_banners, - allow_profile_pictures: settings.accounts.allow_profile_pictures, - nsfw_blur_enabled: settings.media.nsfw_blur_enabled, - max_image_size_mb: bytes_to_mb(settings.media.max_image_size), - max_video_size_mb: bytes_to_mb(settings.media.max_video_size), - } - } - - #[must_use] - pub fn form_value(&self, field: DeepSettingsField) -> String { - match field { - DeepSettingsField::SiteName => self.site_name.clone(), - DeepSettingsField::MaxTextChars => self.max_text_chars.to_string(), - DeepSettingsField::PostEditWindowSeconds => self.post_edit_window_seconds.to_string(), - DeepSettingsField::MaxImagesPerPost => self.max_images_per_post.to_string(), - DeepSettingsField::MaxVideosPerPost => self.max_videos_per_post.to_string(), - DeepSettingsField::MaxMediaPerPost => self.max_media_per_post.to_string(), - DeepSettingsField::AllowReposts => self.allow_reposts.to_string(), - DeepSettingsField::AllowReplies => self.allow_replies.to_string(), - DeepSettingsField::AllowLikes => self.allow_likes.to_string(), - DeepSettingsField::AllowBookmarks => self.allow_bookmarks.to_string(), - DeepSettingsField::AllowHashtags => self.allow_hashtags.to_string(), - DeepSettingsField::AllowMentions => self.allow_mentions.to_string(), - DeepSettingsField::RegistrationEnabled => self.registration_enabled.to_string(), - DeepSettingsField::RegistrationCaptchaEnabled => { - self.registration_captcha_enabled.to_string() - } - DeepSettingsField::AnonymousModeEnabled => self.anonymous_mode_enabled.to_string(), - DeepSettingsField::MinPasswordLength => self.min_password_length.to_string(), - DeepSettingsField::MaxUsernameLen => self.max_username_len.to_string(), - DeepSettingsField::MaxDisplayNameLen => self.max_display_name_len.to_string(), - DeepSettingsField::MaxBioLen => self.max_bio_len.to_string(), - DeepSettingsField::AllowProfileBanners => self.allow_profile_banners.to_string(), - DeepSettingsField::AllowProfilePictures => self.allow_profile_pictures.to_string(), - DeepSettingsField::NsfwBlurEnabled => self.nsfw_blur_enabled.to_string(), - DeepSettingsField::MaxImageSizeMb => self.max_image_size_mb.to_string(), - DeepSettingsField::MaxVideoSizeMb => self.max_video_size_mb.to_string(), - } - } - - #[must_use] - pub fn display_value(&self, field: DeepSettingsField) -> String { - let value = self.form_value(field); - match field { - DeepSettingsField::MaxTextChars - | DeepSettingsField::MinPasswordLength - | DeepSettingsField::MaxUsernameLen - | DeepSettingsField::MaxDisplayNameLen - | DeepSettingsField::MaxBioLen => format!("{value} characters"), - DeepSettingsField::PostEditWindowSeconds => format!("{value} seconds"), - DeepSettingsField::MaxImageSizeMb | DeepSettingsField::MaxVideoSizeMb => { - format!("{value} MB") - } - _ => value, - } - } - - #[must_use] - pub fn apply_to(&self, current: &Settings) -> Settings { - let mut updated = current.clone(); - updated.site.name.clone_from(&self.site_name); - updated.posts.max_text_chars = self.max_text_chars; - updated.posts.post_edit_window_seconds = self.post_edit_window_seconds; - updated.posts.max_images_per_post = self.max_images_per_post; - updated.posts.max_videos_per_post = self.max_videos_per_post; - updated.posts.max_media_per_post = self.max_media_per_post; - updated.posts.allow_reposts = self.allow_reposts; - updated.posts.allow_replies = self.allow_replies; - updated.posts.allow_likes = self.allow_likes; - updated.posts.allow_bookmarks = self.allow_bookmarks; - updated.posts.allow_hashtags = self.allow_hashtags; - updated.posts.allow_mentions = self.allow_mentions; - updated.accounts.registration_enabled = self.registration_enabled; - updated.accounts.registration_captcha_enabled = self.registration_captcha_enabled; - updated.accounts.anonymous_mode_enabled = self.anonymous_mode_enabled; - updated.accounts.min_password_length = self.min_password_length; - updated.accounts.max_username_len = self.max_username_len; - updated.accounts.max_display_name_len = self.max_display_name_len; - updated.accounts.max_bio_len = self.max_bio_len; - updated.accounts.allow_profile_banners = self.allow_profile_banners; - updated.accounts.allow_profile_pictures = self.allow_profile_pictures; - updated.media.nsfw_blur_enabled = self.nsfw_blur_enabled; - updated.media.max_image_size = self.max_image_size_mb * MIB; - updated.media.max_video_size = self.max_video_size_mb * MIB; - updated - } -} - impl BackupSettingsValues { #[must_use] pub fn from_settings(settings: &Settings) -> Self { @@ -496,89 +89,25 @@ pub fn parse_backup_settings_form( form: &BackupSettingsForm, current: &Settings, ) -> anyhow::Result { - let values = BackupSettingsValues { - enabled: parse_named_bool(&form.enabled, "Backups enabled")?, - automatic_enabled: parse_named_bool(&form.automatic_enabled, "Automatic backups enabled")?, - automatic_interval_minutes: parse_named_u64( - &form.automatic_interval_minutes, - "Automatic backup interval", - )?, - retention_keep_last: parse_named_usize(&form.retention_keep_last, "Backups to keep")?, - retention_max_age_days: parse_named_u64( - &form.retention_max_age_days, - "Maximum automatic backup age", - )?, - automatic_include_tor_keys: parse_named_bool( - &form.automatic_include_tor_keys, - "Automatic Tor key backup", - )?, - }; - values.apply_to(current).validate()?; - Ok(values) -} - -pub fn parse_deep_settings_form( - form: &DeepSettingsForm, - current: &Settings, -) -> anyhow::Result { - let values = DeepSettingsValues { - site_name: form.site_name.clone(), - max_text_chars: parse_usize(&form.max_text_chars, DeepSettingsField::MaxTextChars)?, - post_edit_window_seconds: parse_post_edit_window_seconds(&form.post_edit_window_seconds)?, - max_images_per_post: parse_usize( - &form.max_images_per_post, - DeepSettingsField::MaxImagesPerPost, - )?, - max_videos_per_post: parse_usize( - &form.max_videos_per_post, - DeepSettingsField::MaxVideosPerPost, - )?, - max_media_per_post: parse_usize( - &form.max_media_per_post, - DeepSettingsField::MaxMediaPerPost, - )?, - allow_reposts: parse_bool(&form.allow_reposts, DeepSettingsField::AllowReposts)?, - allow_replies: parse_bool(&form.allow_replies, DeepSettingsField::AllowReplies)?, - allow_likes: parse_bool(&form.allow_likes, DeepSettingsField::AllowLikes)?, - allow_bookmarks: parse_bool(&form.allow_bookmarks, DeepSettingsField::AllowBookmarks)?, - allow_hashtags: parse_bool(&form.allow_hashtags, DeepSettingsField::AllowHashtags)?, - allow_mentions: parse_bool(&form.allow_mentions, DeepSettingsField::AllowMentions)?, - registration_enabled: parse_bool( - &form.registration_enabled, - DeepSettingsField::RegistrationEnabled, - )?, - registration_captcha_enabled: parse_bool( - &form.registration_captcha_enabled, - DeepSettingsField::RegistrationCaptchaEnabled, - )?, - anonymous_mode_enabled: parse_bool( - &form.anonymous_mode_enabled, - DeepSettingsField::AnonymousModeEnabled, - )?, - min_password_length: parse_usize( - &form.min_password_length, - DeepSettingsField::MinPasswordLength, - )?, - max_username_len: parse_usize(&form.max_username_len, DeepSettingsField::MaxUsernameLen)?, - max_display_name_len: parse_usize( - &form.max_display_name_len, - DeepSettingsField::MaxDisplayNameLen, - )?, - max_bio_len: parse_usize(&form.max_bio_len, DeepSettingsField::MaxBioLen)?, - allow_profile_banners: parse_bool( - &form.allow_profile_banners, - DeepSettingsField::AllowProfileBanners, - )?, - allow_profile_pictures: parse_bool( - &form.allow_profile_pictures, - DeepSettingsField::AllowProfilePictures, - )?, - nsfw_blur_enabled: parse_bool(&form.nsfw_blur_enabled, DeepSettingsField::NsfwBlurEnabled)?, - max_image_size_mb: parse_mb(&form.max_image_size_mb, DeepSettingsField::MaxImageSizeMb)?, - max_video_size_mb: parse_mb(&form.max_video_size_mb, DeepSettingsField::MaxVideoSizeMb)?, - }; - values.apply_to(current).validate()?; - Ok(values) + let mut shared = DeepSettingsForm::from_settings(current); + shared.backup_enabled.clone_from(&form.enabled); + shared.automatic_enabled.clone_from(&form.automatic_enabled); + shared + .automatic_interval_minutes + .clone_from(&form.automatic_interval_minutes); + shared + .retention_keep_last + .clone_from(&form.retention_keep_last); + shared + .retention_max_age_days + .clone_from(&form.retention_max_age_days); + shared + .automatic_include_tor_keys + .clone_from(&form.automatic_include_tor_keys); + let values = parse_deep_settings_form(&shared, current)?; + Ok(BackupSettingsValues::from_settings( + &values.apply_to(current), + )) } #[must_use] @@ -603,7 +132,7 @@ pub fn write_deep_settings(path: &Path, updated: &Settings) -> anyhow::Result<() updated.validate()?; let raw = fs::read_to_string(path) .with_context(|| format!("failed to read settings file {}", path.display()))?; - let rewritten = rewrite_deep_settings_toml(&raw, updated); + let rewritten = rewrite_settings_toml(&raw, updated, DeepSettingsField::ALL.into_iter())?; let parsed: Settings = toml::from_str(&rewritten) .with_context(|| "rewritten settings.toml did not parse as settings")?; parsed.validate()?; @@ -614,363 +143,147 @@ pub fn write_backup_settings(path: &Path, updated: &Settings) -> anyhow::Result< updated.validate()?; let raw = fs::read_to_string(path) .with_context(|| format!("failed to read settings file {}", path.display()))?; - let rewritten = rewrite_backup_settings_toml(&raw, &updated.backup); + let rewritten = rewrite_settings_toml( + &raw, + updated, + DeepSettingsField::ALL + .into_iter() + .filter(|field| field.toml_section() == "backup"), + )?; let parsed: Settings = toml::from_str(&rewritten) .with_context(|| "rewritten settings.toml did not parse as settings")?; parsed.validate()?; write_atomic(path, rewritten.as_bytes()) } -fn parse_bool(value: &str, field: DeepSettingsField) -> anyhow::Result { - match value { - "true" => Ok(true), - "false" => Ok(false), - _ => anyhow::bail!("{} must be true or false", field.label()), - } -} - -fn parse_usize(value: &str, field: DeepSettingsField) -> anyhow::Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("{} is required", field.label()); - } - if trimmed.starts_with('-') { - anyhow::bail!("{} must not be negative", field.label()); - } - trimmed - .parse::() - .with_context(|| format!("{} must be a whole number", field.label())) +type SpannedFields = std::collections::BTreeMap>; + +// Deserialize only known tables so unrelated scalar or nested extension values +// do not constrain the writer. Unknown data stays untouched in the raw TOML. +#[derive(Deserialize)] +struct ConfigurationSpans { + site: Option>, + server: Option>, + accounts: Option>, + posts: Option>, + media: Option>, + tor: Option>, + moderation: Option>, + admin: Option>, + backup: Option>, } - -fn parse_post_edit_window_seconds(value: &str) -> anyhow::Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("Post edit window is required"); - } - if trimmed.starts_with('-') { - anyhow::bail!("Post edit window must not be negative"); - } - let seconds = trimmed - .parse::() - .with_context(|| "Post edit window must be a whole number of seconds")?; - if seconds > MAX_POST_EDIT_WINDOW_SECONDS { - anyhow::bail!("Post edit window must be {MAX_POST_EDIT_WINDOW_SECONDS} seconds or less"); - } - Ok(seconds) -} - -fn parse_mb(value: &str, field: DeepSettingsField) -> anyhow::Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("{} is required and must be entered in MB", field.label()); - } - if trimmed.starts_with('-') { - anyhow::bail!("{} must not be negative", field.label()); - } - let mb = trimmed - .parse::() - .with_context(|| format!("{} must be a whole number of MB", field.label()))?; - mb.checked_mul(MIB) - .with_context(|| format!("{} is too large to convert from MB", field.label()))?; - Ok(mb) -} - -fn parse_named_bool(value: &str, label: &str) -> anyhow::Result { - match value { - "true" => Ok(true), - "false" => Ok(false), - _ => anyhow::bail!("{label} must be true or false"), - } -} - -fn parse_named_u64(value: &str, label: &str) -> anyhow::Result { - let trimmed = value.trim(); - if trimmed.is_empty() { - anyhow::bail!("{label} is required"); - } - if trimmed.starts_with('-') { - anyhow::bail!("{label} must not be negative"); +impl ConfigurationSpans { + fn get(&self, section: &str) -> Option<&toml::Spanned> { + match section { + "site" => self.site.as_ref(), + "server" => self.server.as_ref(), + "accounts" => self.accounts.as_ref(), + "posts" => self.posts.as_ref(), + "media" => self.media.as_ref(), + "tor" => self.tor.as_ref(), + "moderation" => self.moderation.as_ref(), + "admin" => self.admin.as_ref(), + "backup" => self.backup.as_ref(), + _ => None, + } } - trimmed - .parse::() - .with_context(|| format!("{label} must be a whole number")) -} - -fn parse_named_usize(value: &str, label: &str) -> anyhow::Result { - let parsed = parse_named_u64(value, label)?; - usize::try_from(parsed).with_context(|| format!("{label} is too large")) } -fn bytes_to_mb(bytes: u64) -> u64 { - bytes / MIB -} - -fn rewrite_deep_settings_toml(raw: &str, settings: &Settings) -> String { - let mut output = Vec::new(); - let mut current_section: Option<&str> = None; - let mut found = vec![false; DeepSettingsField::ALL.len()]; - let mut section_seen = Vec::new(); - - for line in raw.lines() { - if let Some(section) = parse_section_header(line) { - append_missing_for_section(&mut output, current_section, &mut found, settings); - if DeepSettingsField::ALL - .iter() - .any(|field| field.toml_section() == section) - { - section_seen.push(section.to_owned()); - } - current_section = Some(section); - output.push(line.to_owned()); +/// Replace only typed, approved settings. TOML value spans handle multiline +/// strings/arrays, quoted keys and inline tables without touching adjacent text. +fn rewrite_settings_toml( + raw: &str, + settings: &Settings, + fields: impl Iterator, +) -> anyhow::Result { + use std::collections::BTreeMap; + let spans: ConfigurationSpans = toml::from_str(raw)?; + let raw_values: toml::Value = toml::from_str(raw)?; + let fields = fields.collect::>(); + let mut inline_sections = std::collections::BTreeSet::new(); + let before: Settings = toml::from_str(raw)?; + let before_values = toml::Value::try_from(&before)?; + let after_values = toml::Value::try_from(settings)?; + let mut edits = Vec::new(); + let mut missing: BTreeMap<&str, Vec> = BTreeMap::new(); + for field in &fields { + let section = field.toml_section(); + let key = field.toml_key(); + let value = &after_values[section][key]; + if value == &before_values[section][key] { continue; } - - if let Some(section) = current_section - && let Some((index, field)) = - DeepSettingsField::ALL - .iter() - .copied() - .enumerate() - .find(|(_, field)| { - field.toml_section() == section && line_assigns_key(line, field.toml_key()) - }) + if let Some(table) = spans.get(section) + && raw[table.span()].trim_start().starts_with('{') { - found[index] = true; - output.push(format!( - "{}{} = {}", - leading_whitespace(line), - field.toml_key(), - toml_value(field, settings) - )); - continue; - } - - output.push(line.to_owned()); - } - - append_missing_for_section(&mut output, current_section, &mut found, settings); - for section in ["site", "posts", "accounts", "media"] { - if !section_seen.iter().any(|seen| seen == section) { - output.push(String::new()); - output.push(format!("[{section}]")); - append_missing_for_section(&mut output, Some(section), &mut found, settings); - } - } - - let mut rewritten = output.join("\n"); - if raw.ends_with('\n') { - rewritten.push('\n'); - } - rewritten -} - -fn rewrite_backup_settings_toml(raw: &str, settings: &BackupSettings) -> String { - let mut output = Vec::new(); - let mut current_section: Option<&str> = None; - let mut found = vec![false; BACKUP_SETTING_KEYS.len()]; - let mut backup_section_seen = false; - - for line in raw.lines() { - if let Some(section) = parse_section_header(line) { - append_missing_backup_settings(&mut output, current_section, &mut found, settings); - if section == "backup" { - backup_section_seen = true; + if inline_sections.insert(section) { + let mut replacement = raw_values[section].clone(); + for field in &fields { + if field.toml_section() == section { + replacement[field.toml_key()] = + after_values[section][field.toml_key()].clone(); + } + } + edits.push((table.span(), replacement.to_string())); } - current_section = Some(section); - output.push(line.to_owned()); continue; } - - if current_section == Some("backup") - && let Some((index, key)) = BACKUP_SETTING_KEYS - .iter() - .copied() - .enumerate() - .find(|(_, key)| line_assigns_key(line, key)) + if let Some(existing) = spans + .get(section) + .and_then(|table| table.get_ref().get(key)) { - found[index] = true; - output.push(format!( - "{}{} = {}", - leading_whitespace(line), - key, - backup_toml_value(key, settings) - )); - continue; - } - - output.push(line.to_owned()); - } - - append_missing_backup_settings(&mut output, current_section, &mut found, settings); - if !backup_section_seen { - output.push(String::new()); - output.push("[backup]".to_owned()); - append_missing_backup_settings(&mut output, Some("backup"), &mut found, settings); - } - - let mut rewritten = output.join("\n"); - if raw.ends_with('\n') { - rewritten.push('\n'); - } - rewritten -} - -const BACKUP_SETTING_KEYS: [&str; 6] = [ - "enabled", - "automatic_enabled", - "automatic_interval_minutes", - "retention_keep_last", - "retention_max_age_days", - "automatic_include_tor_keys", -]; - -fn append_missing_backup_settings( - output: &mut Vec, - section: Option<&str>, - found: &mut [bool], - settings: &BackupSettings, -) { - if section != Some("backup") { - return; - } - for (index, key) in BACKUP_SETTING_KEYS.iter().copied().enumerate() { - if !found[index] { - found[index] = true; - output.push(format!("{key} = {}", backup_toml_value(key, settings))); + edits.push((existing.span(), value.to_string())); + } else { + missing + .entry(section) + .or_default() + .push(format!("{key} = {value}\n")); } } -} - -fn backup_toml_value(key: &str, settings: &BackupSettings) -> String { - match key { - "enabled" => settings.enabled.to_string(), - "automatic_enabled" => settings.automatic_enabled.to_string(), - "automatic_interval_minutes" => settings.automatic_interval_minutes.to_string(), - "retention_keep_last" => settings.retention_keep_last.to_string(), - "retention_max_age_days" => settings.retention_max_age_days.to_string(), - "automatic_include_tor_keys" => settings.automatic_include_tor_keys.to_string(), - _ => String::new(), - } -} - -fn append_missing_for_section( - output: &mut Vec, - section: Option<&str>, - found: &mut [bool], - settings: &Settings, -) { - let Some(section) = section else { - return; - }; - for (index, field) in DeepSettingsField::ALL.iter().copied().enumerate() { - if field.toml_section() == section && !found[index] { - found[index] = true; - output.push(format!( - "{} = {}", - field.toml_key(), - toml_value(field, settings) + for (section, lines) in missing { + if let Some(table) = spans.get(section) { + let end = table.span().end; + // Missing fields can only be inserted in a normal table. Refuse + // unsupported shapes instead of silently producing corrupt TOML. + if raw[..end].trim_end().ends_with('}') { + anyhow::bail!( + "Missing setting in inline {section} table; expand the table before editing" + ); + } + edits.push((end..end, format!("\n{}", lines.join("")))); + } else { + edits.push(( + raw.len()..raw.len(), + format!("\n[{section}]\n{}", lines.join("")), )); } } -} - -fn parse_section_header(line: &str) -> Option<&str> { - let trimmed = line.trim(); - trimmed.strip_prefix('[')?.strip_suffix(']') -} - -fn line_assigns_key(line: &str, key: &str) -> bool { - let trimmed = line.trim_start(); - if trimmed.starts_with('#') { - return false; - } - let Some(rest) = trimmed.strip_prefix(key) else { - return false; - }; - rest.trim_start().starts_with('=') -} - -fn leading_whitespace(line: &str) -> &str { - let end = line - .char_indices() - .find_map(|(index, ch)| (!ch.is_whitespace()).then_some(index)) - .unwrap_or(line.len()); - &line[..end] -} - -fn toml_value(field: DeepSettingsField, settings: &Settings) -> String { - match field { - DeepSettingsField::SiteName => toml::Value::String(settings.site.name.clone()).to_string(), - DeepSettingsField::MaxTextChars => settings.posts.max_text_chars.to_string(), - DeepSettingsField::PostEditWindowSeconds => { - settings.posts.post_edit_window_seconds.to_string() - } - DeepSettingsField::MaxImagesPerPost => settings.posts.max_images_per_post.to_string(), - DeepSettingsField::MaxVideosPerPost => settings.posts.max_videos_per_post.to_string(), - DeepSettingsField::MaxMediaPerPost => settings.posts.max_media_per_post.to_string(), - DeepSettingsField::AllowReposts => settings.posts.allow_reposts.to_string(), - DeepSettingsField::AllowReplies => settings.posts.allow_replies.to_string(), - DeepSettingsField::AllowLikes => settings.posts.allow_likes.to_string(), - DeepSettingsField::AllowBookmarks => settings.posts.allow_bookmarks.to_string(), - DeepSettingsField::AllowHashtags => settings.posts.allow_hashtags.to_string(), - DeepSettingsField::AllowMentions => settings.posts.allow_mentions.to_string(), - DeepSettingsField::RegistrationEnabled => { - settings.accounts.registration_enabled.to_string() - } - DeepSettingsField::RegistrationCaptchaEnabled => { - settings.accounts.registration_captcha_enabled.to_string() - } - DeepSettingsField::AnonymousModeEnabled => { - settings.accounts.anonymous_mode_enabled.to_string() - } - DeepSettingsField::MinPasswordLength => settings.accounts.min_password_length.to_string(), - DeepSettingsField::MaxUsernameLen => settings.accounts.max_username_len.to_string(), - DeepSettingsField::MaxDisplayNameLen => settings.accounts.max_display_name_len.to_string(), - DeepSettingsField::MaxBioLen => settings.accounts.max_bio_len.to_string(), - DeepSettingsField::AllowProfileBanners => { - settings.accounts.allow_profile_banners.to_string() - } - DeepSettingsField::AllowProfilePictures => { - settings.accounts.allow_profile_pictures.to_string() - } - DeepSettingsField::NsfwBlurEnabled => settings.media.nsfw_blur_enabled.to_string(), - DeepSettingsField::MaxImageSizeMb => settings.media.max_image_size.to_string(), - DeepSettingsField::MaxVideoSizeMb => settings.media.max_video_size.to_string(), + edits.sort_by_key(|(left, _)| std::cmp::Reverse(left.start)); + let mut rewritten = raw.to_owned(); + for (span, value) in edits { + rewritten.replace_range(span, &value); } + let parsed: Settings = + toml::from_str(&rewritten).context("Updated TOML could not be parsed")?; + parsed.validate()?; + Ok(rewritten) } fn write_atomic(path: &Path, bytes: &[u8]) -> anyhow::Result<()> { let parent = path.parent().unwrap_or_else(|| Path::new(".")); - let file_name = path - .file_name() - .and_then(|name| name.to_str()) - .context("settings path must have a valid file name")?; - let tmp_path = parent.join(format!(".{file_name}.tmp")); - { - let mut tmp = File::create(&tmp_path).with_context(|| { - format!( - "failed to create temporary settings file {}", - tmp_path.display() - ) - })?; - tmp.write_all(bytes).with_context(|| { - format!( - "failed to write temporary settings file {}", - tmp_path.display() - ) - })?; - tmp.sync_all().with_context(|| { - format!( - "failed to sync temporary settings file {}", - tmp_path.display() - ) - })?; - } - fs::rename(&tmp_path, path) - .with_context(|| format!("failed to replace settings file {}", path.display()))?; - if let Ok(parent_dir) = File::open(parent) { - let _sync_result = parent_dir.sync_all(); - } + let metadata = fs::symlink_metadata(path)?; + if !metadata.file_type().is_file() { + anyhow::bail!("Settings must be a regular file, not a symbolic link"); + } + let mut temporary = tempfile::NamedTempFile::new_in(parent)?; + temporary + .as_file() + .set_permissions(metadata.permissions())?; + temporary.write_all(bytes)?; + temporary.as_file().sync_all()?; + temporary.persist(path).map_err(|error| error.error)?; + #[cfg(unix)] + File::open(parent)?.sync_all()?; Ok(()) } @@ -1533,35 +846,7 @@ mod tests { } fn form_from_settings(settings: &Settings) -> DeepSettingsForm { - let values = DeepSettingsValues::from_settings(settings); - DeepSettingsForm { - csrf: "csrf".to_owned(), - intent: Some("preview".to_owned()), - site_name: values.site_name, - max_text_chars: values.max_text_chars.to_string(), - post_edit_window_seconds: values.post_edit_window_seconds.to_string(), - max_images_per_post: values.max_images_per_post.to_string(), - max_videos_per_post: values.max_videos_per_post.to_string(), - max_media_per_post: values.max_media_per_post.to_string(), - allow_reposts: values.allow_reposts.to_string(), - allow_replies: values.allow_replies.to_string(), - allow_likes: values.allow_likes.to_string(), - allow_bookmarks: values.allow_bookmarks.to_string(), - allow_hashtags: values.allow_hashtags.to_string(), - allow_mentions: values.allow_mentions.to_string(), - registration_enabled: values.registration_enabled.to_string(), - registration_captcha_enabled: values.registration_captcha_enabled.to_string(), - anonymous_mode_enabled: values.anonymous_mode_enabled.to_string(), - min_password_length: values.min_password_length.to_string(), - max_username_len: values.max_username_len.to_string(), - max_display_name_len: values.max_display_name_len.to_string(), - max_bio_len: values.max_bio_len.to_string(), - allow_profile_banners: values.allow_profile_banners.to_string(), - allow_profile_pictures: values.allow_profile_pictures.to_string(), - nsfw_blur_enabled: values.nsfw_blur_enabled.to_string(), - max_image_size_mb: values.max_image_size_mb.to_string(), - max_video_size_mb: values.max_video_size_mb.to_string(), - } + DeepSettingsForm::from_settings(settings) } #[test] @@ -1725,7 +1010,7 @@ mod tests { let form = form_from_settings(&settings); let parsed = parse_deep_settings_form(&form, &settings).expect("valid form"); - assert!(diff_deep_settings(&settings, &parsed).is_empty()); + assert_eq!(diff_deep_settings(&settings, &parsed).len(), 0); } #[test] @@ -1824,4 +1109,97 @@ mod tests { Settings::default().media.ffmpeg_path ); } + #[test] + fn persistence_handles_multiline_values_and_preserves_unknown_settings() { + let temp = tempdir().expect("temp"); + let path = temp.path().join("settings.toml"); + crate::config::write_default_if_missing(&path).expect("defaults"); + let raw = fs::read_to_string(&path).expect("read") + .replace("name = \"RustPost\"", "name = \"\"\"RustPost\"\"\" # inline comment") + .replace("allowed_image_mime_types = [\"image/jpeg\", \"image/png\", \"image/gif\", \"image/webp\"]", "allowed_image_mime_types = [\n \"image/jpeg\", # keep comment\n \"image/png\",\n]"); + let raw = format!("{raw}\n[custom]\nprivate_key = \"never-display-this\"\n"); + fs::write(&path, &raw).expect("fixture"); + let mut updated = Settings::load(&path).expect("load"); + updated.site.name = r#"Quotes " and \"#.to_owned(); + updated.media.allowed_image_mime_types = vec!["image/webp".to_owned()]; + write_deep_settings(&path, &updated).expect("persist"); + let reloaded = Settings::load(&path).expect("reload"); + assert_eq!(reloaded.site.name, updated.site.name); + assert_eq!( + reloaded.media.allowed_image_mime_types, + updated.media.allowed_image_mime_types + ); + let persisted = fs::read_to_string(&path).expect("read"); + assert!(persisted.contains("# inline comment")); + assert!(persisted.contains("private_key = \"never-display-this\"")); + } + + #[test] + fn persistence_adds_optional_defaulted_fields_and_keeps_unrelated_values() { + let temp = tempdir().expect("temp"); + let path = temp.path().join("settings.toml"); + crate::config::write_default_if_missing(&path).expect("defaults"); + let raw = fs::read_to_string(&path).expect("read"); + let raw = raw + .lines() + .filter(|line| !line.starts_with("deletion_grace_period_days =")) + .collect::>() + .join("\n"); + fs::write(&path, &raw).expect("fixture"); + let mut updated = Settings::load(&path).expect("load"); + updated.accounts.deletion_grace_period_days = 7; + updated.media.ffmpeg_path = "must-not-change".to_owned(); + write_deep_settings(&path, &updated).expect("persist"); + let reloaded = Settings::load(&path).expect("reload"); + assert_eq!(reloaded.accounts.deletion_grace_period_days, 7); + assert_eq!(reloaded.media.ffmpeg_path, "ffmpeg"); + } + + #[test] + fn invalid_config_never_changes_the_existing_file() { + let temp = tempdir().expect("temp"); + let path = temp.path().join("settings.toml"); + crate::config::write_default_if_missing(&path).expect("defaults"); + let before = fs::read(&path).expect("read"); + let mut updated = Settings::default(); + updated.media.vp9_crf = 255; + assert!(write_deep_settings(&path, &updated).is_err()); + assert_eq!(fs::read(&path).expect("read"), before); + } + + #[test] + fn atomic_write_failure_preserves_config_and_cleans_temporary_files() { + let temp = tempdir().expect("temp"); + let path = temp.path().join("settings.toml"); + fs::create_dir(&path).expect("directory at destination"); + assert!(write_atomic(&path, b"invalid").is_err()); + assert!(path.is_dir()); + assert_eq!(fs::read_dir(temp.path()).expect("entries").count(), 1); + } + #[test] + fn persistence_preserves_root_extensions_and_updates_inline_tables() { + let temp = tempdir().expect("temp"); + let path = temp.path().join("settings.toml"); + crate::config::write_default_if_missing(&path).expect("defaults"); + let raw = fs::read_to_string(&path).expect("read"); + let raw = format!("extension = 42\nsite = {{ name = \"RustPost\" }}\n{raw}"); + let raw = raw.replace("[site]", "# site already defined").replacen( + "name = \"RustPost\"\n", + "", + 1, + ); + fs::write(&path, raw).expect("fixture"); + let mut updated = Settings::load(&path).expect("load"); + updated.site.name = "Inline site".to_owned(); + write_deep_settings(&path, &updated).expect("save"); + assert_eq!( + Settings::load(&path).expect("reload").site.name, + "Inline site" + ); + assert!( + fs::read_to_string(&path) + .expect("read") + .contains("extension = 42") + ); + } } diff --git a/src/backup.rs b/src/backup.rs index 7a1770e..6a7d0af 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -219,7 +219,18 @@ pub fn spawn_automatic_scheduler( mut shutdown_rx: watch::Receiver, ) { tokio::spawn(async move { + let update_socket = + crate::updates::managed().then(|| PathBuf::from(crate::updates::SOCKET)); loop { + if !crate::updates::mutations_allowed(update_socket.as_deref()).await { + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { break; } + } + () = tokio::time::sleep(Duration::from_secs(1)) => {} + } + continue; + } match Settings::load(&settings_path).and_then(|settings| { settings.validate()?; run_automatic_backup_if_due(&base_paths, &settings) @@ -1338,16 +1349,35 @@ fn remove_path_if_exists(path: &Path) -> anyhow::Result<()> { Ok(()) } +/// Cross-process coordination between ordinary backups/restores and updates. +/// OS locking releases on interruption; the file itself is never removed. +pub(crate) fn update_coordination_lock(paths: &RuntimePaths) -> anyhow::Result { + let file = File::options() + .read(true) + .write(true) + .create(true) + .truncate(false) + .open(paths.tmp_dir.join("update-coordination.lock"))?; + file.try_lock() + .with_context(|| "another backup, restore, or update is running")?; + Ok(file) +} + struct OperationLock { path: PathBuf, + _coordination: File, } impl OperationLock { fn acquire(paths: &RuntimePaths) -> anyhow::Result { fs::create_dir_all(&paths.tmp_dir)?; + let coordination = update_coordination_lock(paths)?; let path = paths.tmp_dir.join(LOCK_DIR); match fs::create_dir(&path) { - Ok(()) => Ok(Self { path }), + Ok(()) => Ok(Self { + path, + _coordination: coordination, + }), Err(error) if error.kind() == io::ErrorKind::AlreadyExists => { anyhow::bail!("another backup or restore is already running"); } diff --git a/src/bin/rustpost-updater.rs b/src/bin/rustpost-updater.rs new file mode 100644 index 0000000..855b369 --- /dev/null +++ b/src/bin/rustpost-updater.rs @@ -0,0 +1,14 @@ +use clap::Parser; + +#[derive(Parser)] +#[command(version, about = "Restricted native Linux RustPost updater")] +struct Arguments { + #[arg(long, default_value = "/etc/rustpost-updater/updater.toml")] + config: std::path::PathBuf, +} + +#[tokio::main] +async fn main() -> anyhow::Result<()> { + rustpost::logging::init(); + rustpost::updates::run(&Arguments::parse().config).await +} diff --git a/src/cli.rs b/src/cli.rs index e0d6e29..daacc4e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -28,6 +28,10 @@ struct Cli { #[arg(long, help = "Runtime data directory")] data_dir: Option, + /// Print machine-readable release compatibility without touching runtime state. + #[arg(long)] + update_info: bool, + #[command(subcommand)] command: Option, } @@ -92,6 +96,13 @@ enum Command { pub async fn run() -> anyhow::Result<()> { logging::init(); let cli = Cli::parse(); + if cli.update_info { + stdout_line(format_args!( + "{}", + serde_json::json!({ "version": crate::updates::VERSION, "schema": db::CURRENT_SCHEMA_VERSION, "minimum_schema": 1, "target": crate::updates::platform_target(), "updater_protocol": 1 }) + ))?; + return Ok(()); + } let explicit_data_dir = cli.data_dir.clone(); let mut paths = runtime::RuntimePaths::discover(cli.data_dir.as_deref())?; paths.ensure()?; @@ -317,6 +328,16 @@ async fn serve( settings_path: PathBuf, settings: config::Settings, ) -> anyhow::Result<()> { + if crate::updates::managed() { + let reply = crate::updates::request(&crate::updates::Request::Ready { + version: crate::updates::VERSION.into(), + }) + .await?; + anyhow::ensure!( + reply.ready && reply.error.is_none(), + "updater recovery must complete before RustPost can migrate or start" + ); + } let pool = db::connect(&paths.database_path).await?; db::migrate(&pool).await?; let admin_count = ensure_first_admin_interactive(&pool, &settings, &paths).await?; @@ -485,9 +506,7 @@ async fn serve_clearnet( settings: &config::ServerSettings, shutdown_rx: watch::Receiver, ) -> anyhow::Result<()> { - let addr: SocketAddr = format!("{}:{}", settings.host, settings.port) - .parse() - .with_context(|| "invalid server bind address")?; + let addr = settings.listener_address()?; let clearnet_listener = tokio::net::TcpListener::bind(addr) .await .with_context(|| format!("bind RustPost server at {addr}"))?; diff --git a/src/compression.rs b/src/compression.rs index a7d31b5..12adb32 100644 --- a/src/compression.rs +++ b/src/compression.rs @@ -117,15 +117,10 @@ fn should_buffer_head_response(path: &str, response: &Response) -> bool { async fn head_response_with_get_length(response: Response) -> Response { let (mut parts, body) = response.into_parts(); match to_bytes(body, MAX_BUFFERED_RESPONSE_BYTES).await { - Ok(body) => { - set_content_length(&mut parts.headers, body.len()); - Response::from_parts(parts, Body::empty()) - } - Err(error) => { - tracing::warn!(error = %error, "failed to buffer HEAD response"); - Response::from_parts(parts, Body::empty()) - } + Ok(body) => set_content_length(&mut parts.headers, body.len()), + Err(error) => tracing::warn!(error = %error, "failed to buffer HEAD response"), } + Response::from_parts(parts, Body::empty()) } fn body_for_method(body: Vec, is_head: bool) -> Body { diff --git a/src/config.rs b/src/config.rs index 0823a68..3e2547d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,3 +1,5 @@ +pub mod admin_fields; + use std::fs; use std::path::Path; @@ -285,6 +287,7 @@ impl Settings { } pub fn validate(&self) -> anyhow::Result<()> { + self.validate_network_and_media()?; let site_name = self.site.name.trim(); if site_name != self.site.name { anyhow::bail!("site.name must not contain surrounding whitespace"); @@ -345,6 +348,73 @@ impl Settings { } Ok(()) } + fn validate_network_and_media(&self) -> anyhow::Result<()> { + self.server.listener_address()?; + if !self.server.public_url.is_empty() { + let url: axum::http::Uri = self.server.public_url.parse().map_err(|_| { + anyhow::anyhow!("server.public_url must be an absolute HTTP(S) URL") + })?; + if !matches!(url.scheme_str(), Some("http" | "https")) + || url + .authority() + .is_none_or(|host| host.as_str().contains('@')) + { + anyhow::bail!( + "server.public_url must be an absolute HTTP(S) URL without credentials" + ); + } + } + for cidr in &self.server.trusted_proxy_cidrs { + let (address, prefix) = cidr.split_once('/').unwrap_or((cidr, "")); + let address: std::net::IpAddr = address.parse().map_err(|_| { + anyhow::anyhow!( + "server.trusted_proxy_cidrs must contain valid IP addresses or CIDRs" + ) + })?; + if !prefix.is_empty() { + let bits: u8 = prefix.parse().map_err(|_| { + anyhow::anyhow!("server.trusted_proxy_cidrs contains an invalid prefix") + })?; + if bits > if address.is_ipv4() { 32 } else { 128 } { + anyhow::bail!("server.trusted_proxy_cidrs contains an out-of-range prefix"); + } + } else if cidr.contains('/') { + anyhow::bail!("server.trusted_proxy_cidrs contains an empty prefix"); + } + } + if self.media.webp_quality > 100 { + anyhow::bail!("media.webp_quality must be 0–100"); + } + if self.media.vp9_crf > 63 { + anyhow::bail!("media.vp9_crf must be 0–63"); + } + self.media.vp9_deadline.parse::()?; + for (key, types) in [ + ( + "media.allowed_image_mime_types", + &self.media.allowed_image_mime_types, + ), + ( + "media.allowed_video_mime_types", + &self.media.allowed_video_mime_types, + ), + ] { + for mime in types { + if mime.split_once('/').is_none_or(|(kind, subtype)| { + kind.is_empty() || subtype.is_empty() || subtype.contains('/') + }) || !mime + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"/-+._".contains(&b)) + { + anyhow::bail!("{key} contains an invalid MIME type"); + } + } + } + if self.tor.max_concurrent_streams > u32::MAX as usize { + anyhow::bail!("tor.max_concurrent_streams must fit in a 32-bit unsigned integer"); + } + Ok(()) + } } const fn default_post_edit_window_seconds() -> u64 { @@ -830,7 +900,7 @@ mod tests { .join("\n"); let parsed: Settings = toml::from_str(&raw).expect("legacy settings parse"); - assert!(parsed.tor.display_onion_address.is_empty()); + assert_eq!(parsed.tor.display_onion_address.len(), 0); } #[test] @@ -1068,3 +1138,49 @@ mod tests { assert!(!settings.accounts.registration_captcha_enabled); } } + +/// `FFmpeg` VP9 deadline choices, shared by configuration validation and admin. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum VideoEncodingSpeed { + Best, + Good, + Realtime, +} +impl VideoEncodingSpeed { + pub const ALL: [Self; 3] = [Self::Best, Self::Good, Self::Realtime]; + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Best => "best", + Self::Good => "good", + Self::Realtime => "realtime", + } + } +} +impl std::str::FromStr for VideoEncodingSpeed { + type Err = anyhow::Error; + fn from_str(value: &str) -> Result { + Self::ALL + .into_iter() + .find(|speed| speed.as_str() == value) + .ok_or_else(|| anyhow::anyhow!("media.vp9_deadline must be best, good or realtime")) + } +} + +impl ServerSettings { + /// Validated listener address, preserving bracketed IPv6 TOML compatibility. + /// + /// # Errors + /// Returns an error if the host is not an IPv4 or IPv6 address. + pub fn listener_address(&self) -> anyhow::Result { + let host = self + .host + .strip_prefix('[') + .and_then(|host| host.strip_suffix(']')) + .unwrap_or(&self.host); + let ip = host + .parse::() + .map_err(|_| anyhow::anyhow!("server.host must be an IP address"))?; + Ok(std::net::SocketAddr::new(ip, self.port)) + } +} diff --git a/src/config/admin_fields.rs b/src/config/admin_fields.rs new file mode 100644 index 0000000..80ac9b1 --- /dev/null +++ b/src/config/admin_fields.rs @@ -0,0 +1,471 @@ +//! Typed contract between the configuration model and web administration. +//! Every accessor names a real Settings field; the inventory test detects additions. +use super::Settings; +use anyhow::Context as _; +use serde::Deserialize; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum DeepSettingsInputKind { + Text, + Url, + Number, + Size, + Boolean, + List, + Encoding, +} + +pub const NON_WEB_SETTINGS: [(&str, &str); 4] = [ + ( + "media.ffmpeg_path", + "Executable selection permits process execution with the server's privileges; deployment managed.", + ), + ( + "tor.data_dir", + "Moving private onion identity storage requires a coordinated filesystem migration; changing a path alone can lose the identity.", + ), + ( + "backup.backup_dir", + "Moving backup storage requires a coordinated filesystem migration; runtime paths are fixed at startup.", + ), + ( + "tor.include_tor_keys_in_backups_by_default", + "Validation requires false. Private keys require explicit consent on each manual backup or the separately configured automatic-backup policy.", + ), +]; + +macro_rules! admin_fields { + ($( $variant:ident, $name:ident: $ty:ty, $section:ident.$key:ident, $label:literal, $helper:literal, $kind:ident, [$($attr:meta),*]; )*) => { + #[derive(Debug, Clone, Deserialize)] + #[serde(deny_unknown_fields)] + pub struct DeepSettingsForm { + pub csrf: String, + pub intent: Option, + pub revision: Option, + $( $(#[$attr])* pub $name: String,)* + } + #[derive(Debug, Clone, Copy, PartialEq, Eq)] + pub enum DeepSettingsField { $($variant,)* } + #[derive(Debug, Clone, PartialEq, Eq)] + pub struct DeepSettingsValues { $(pub $name: $ty,)* } + impl DeepSettingsField { + pub const ALL: [Self; 61] = [$(Self::$variant,)*]; + #[must_use] + pub const fn toml_section(self) -> &'static str { match self { $(Self::$variant => stringify!($section),)* } } + #[must_use] + pub const fn toml_key(self) -> &'static str { match self { $(Self::$variant => stringify!($key),)* } } + #[must_use] + pub const fn form_name(self) -> &'static str { match self { $(Self::$variant => stringify!($name),)* } } + #[must_use] + pub const fn label(self) -> &'static str { match self { $(Self::$variant => $label,)* } } + #[must_use] + pub const fn helper(self) -> Option<&'static str> { match self { $(Self::$variant => Some($helper),)* } } + #[must_use] + pub const fn input_kind(self) -> DeepSettingsInputKind { match self { $(Self::$variant => DeepSettingsInputKind::$kind,)* } } + } + impl DeepSettingsValues { + #[must_use] + pub fn from_settings(settings: &Settings) -> Self { Self { $($name: SettingsValue::copy_value(&settings.$section.$key),)* } } + #[must_use] + pub fn apply_to(&self, current: &Settings) -> Settings { + let mut updated = current.clone(); + $(updated.$section.$key = SettingsValue::copy_value(&self.$name);)* + updated + } + #[must_use] + pub fn form_value(&self, field: DeepSettingsField) -> String { match field { $(DeepSettingsField::$variant => self.$name.formatted(field.input_kind()),)* } } + } + impl DeepSettingsForm { + #[must_use] + pub fn from_settings(settings: &Settings) -> Self { + let values = DeepSettingsValues::from_settings(settings); + Self { csrf: String::new(), revision: None, intent: Some("preview".to_owned()), $($name: values.form_value(DeepSettingsField::$variant),)* } + } + #[must_use] + pub fn submitted_value(&self, field: DeepSettingsField) -> &str { match field { $(DeepSettingsField::$variant => &self.$name,)* } } + } + pub fn parse_deep_settings_form(form: &DeepSettingsForm, current: &Settings) -> anyhow::Result { + let values = DeepSettingsValues { $($name: SettingsValue::parse(&form.$name, DeepSettingsField::$variant)?,)* }; + values.apply_to(current).validate().map_err(|error| { + let message = error.to_string(); + for field in DeepSettingsField::ALL { + let key = format!("{}.{}", field.toml_section(), field.toml_key()); + if let Some(rest) = message.strip_prefix(&key) { + return anyhow::anyhow!("{}{rest}", field.label()); + } + } + error + })?; + Ok(values) + } + } +} + +admin_fields! { + SiteName, site_name: String, site.name, "Site name", "Shown in page titles, the header, and the footer. Empty hides the display name.", Text, []; + RegistrationEnabled, registration_enabled: bool, accounts.registration_enabled, "Registration enabled", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + RegistrationCaptchaEnabled, registration_captcha_enabled: bool, accounts.registration_captcha_enabled, "Registration CAPTCHA enabled", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + AnonymousModeEnabled, anonymous_mode_enabled: bool, accounts.anonymous_mode_enabled, "Anonymous posting enabled", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + MinPasswordLength, min_password_length: usize, accounts.min_password_length, "Minimum password length", "Characters. Recommended default is 10; 0 permits empty passwords.", Number, []; + MaxUsernameLen, max_username_len: usize, accounts.max_username_len, "Maximum username length", "Characters.", Number, []; + MaxDisplayNameLen, max_display_name_len: usize, accounts.max_display_name_len, "Maximum display name length", "Characters.", Number, []; + MaxBioLen, max_bio_len: usize, accounts.max_bio_len, "Maximum bio length", "Characters.", Number, []; + AllowProfileBanners, allow_profile_banners: bool, accounts.allow_profile_banners, "Allow profile banners", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + AllowProfilePictures, allow_profile_pictures: bool, accounts.allow_profile_pictures, "Allow profile pictures", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + DeletionGracePeriodDays, deletion_grace_period_days: u64, accounts.deletion_grace_period_days, "Account deletion grace period", "Days, 0–3650. Set 0 for immediate deletion.", Number, []; + MaxArchiveUploadBytes, max_archive_upload_bytes: u64, accounts.max_archive_upload_bytes, "Maximum compressed account archive", "MiB. Applies to imports and exports; 0.0625–2048 MiB.", Size, []; + MaxArchiveExpandedBytes, max_archive_expanded_bytes: u64, accounts.max_archive_expanded_bytes, "Maximum expanded account archive", "MiB of decompressed media; 0.0625–16384 MiB.", Size, []; + MaxArchiveEntries, max_archive_entries: usize, accounts.max_archive_entries, "Maximum archive entries", "Tar entries per import, 1–100000.", Number, []; + MaxTextChars, max_text_chars: usize, posts.max_text_chars, "Maximum post text length", "Characters.", Number, []; + PostEditWindowSeconds, post_edit_window_seconds: u64, posts.post_edit_window_seconds, "Post edit window", "Seconds, 0–300. Set 0 to disable editing.", Number, []; + MaxImagesPerPost, max_images_per_post: usize, posts.max_images_per_post, "Maximum images per post", "Attachments per post.", Number, []; + MaxVideosPerPost, max_videos_per_post: usize, posts.max_videos_per_post, "Maximum videos per post", "Attachments per post.", Number, []; + MaxMediaPerPost, max_media_per_post: usize, posts.max_media_per_post, "Maximum total media per post", "Attachments per post.", Number, []; + AllowReposts, allow_reposts: bool, posts.allow_reposts, "Allow reposts", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + AllowReplies, allow_replies: bool, posts.allow_replies, "Allow replies", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + AllowLikes, allow_likes: bool, posts.allow_likes, "Allow likes", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + AllowBookmarks, allow_bookmarks: bool, posts.allow_bookmarks, "Allow bookmarks", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + AllowHashtags, allow_hashtags: bool, posts.allow_hashtags, "Allow hashtags", "Reserved: hashtag extraction currently operates regardless of this flag.", Boolean, [serde(default = "unchecked")]; + AllowMentions, allow_mentions: bool, posts.allow_mentions, "Allow mentions", "Allow this feature.", Boolean, [serde(default = "unchecked")]; + NsfwBlurEnabled, nsfw_blur_enabled: bool, media.nsfw_blur_enabled, "Blur NSFW media", "Blur flagged media unless a user disables their own blur setting.", Boolean, [serde(default = "unchecked")]; + MaxImageSizeMb, max_image_size_mb: u64, media.max_image_size, "Maximum image size", "MiB per image (1 MiB = 1,048,576 bytes). Decimal values are accepted.", Size, []; + MaxVideoSizeMb, max_video_size_mb: u64, media.max_video_size, "Maximum video size", "MiB per video (1 MiB = 1,048,576 bytes). Decimal values are accepted.", Size, []; + ConvertImagesToWebp, convert_images_to_webp: bool, media.convert_images_to_webp, "Convert images to WebP", "Convert accepted images using the existing image processor.", Boolean, [serde(default = "unchecked")]; + ConvertVideosToWebm, convert_videos_to_webm: bool, media.convert_videos_to_webm, "Convert videos to WebM", "Convert accepted videos using FFmpeg.", Boolean, [serde(default = "unchecked")]; + KeepOriginalUploads, keep_original_uploads: bool, media.keep_original_uploads, "Keep original uploads", "Retain original uploads in addition to converted media.", Boolean, [serde(default = "unchecked")]; + GenerateVideoThumbnails, generate_video_thumbnails: bool, media.generate_video_thumbnails, "Generate video thumbnails", "Use FFmpeg to extract video previews.", Boolean, [serde(default = "unchecked")]; + AllowedImageMimeTypes, allowed_image_mime_types: Vec, media.allowed_image_mime_types, "Allowed image formats", "One MIME type per line, for example image/jpeg. Empty disables image uploads.", List, []; + AllowedVideoMimeTypes, allowed_video_mime_types: Vec, media.allowed_video_mime_types, "Allowed video formats", "One MIME type per line, for example video/mp4. Empty disables video uploads.", List, []; + WebpQuality, webp_quality: u8, media.webp_quality, "WebP quality", "Image quality, 0–100. Higher values produce larger files.", Number, []; + Vp9Crf, vp9_crf: u8, media.vp9_crf, "Video quality", "VP9 constant quality, 0–63. Lower values produce larger files.", Number, []; + Vp9Deadline, vp9_deadline: String, media.vp9_deadline, "Video encoding speed", "Best prioritizes quality; good balances speed; realtime prioritizes speed.", Encoding, []; + Host, host: String, server.host, "Listener address", "IP address to listen on. A restart can disconnect this console.", Text, []; + Port, port: u16, server.port, "Listener port", "TCP port, 0–65535. Port 0 asks the OS to choose a port. Restart required.", Number, []; + PublicUrl, public_url: String, server.public_url, "Public URL", "Optional HTTP(S) URL shown in the startup dashboard. RustPost currently generates relative links regardless of this value.", Url, []; + CookieSecure, cookie_secure: bool, server.cookie_secure, "Secure session cookies", "Enable only when visitors always use HTTPS.", Boolean, [serde(default = "unchecked")]; + TrustedProxyCidrs, trusted_proxy_cidrs: Vec, server.trusted_proxy_cidrs, "Trusted reverse proxies", "One IP/CIDR per line. Reserved: RustPost currently ignores forwarded IP headers and uses the direct connection address.", List, []; + PostsPerMinute, posts_per_minute: i64, moderation.posts_per_minute, "Post rate limit", "Posts per account per minute; values of 0 or less block posting.", Number, []; + RepliesPerMinute, replies_per_minute: i64, moderation.replies_per_minute, "Reply rate limit", "Replies per account per minute; values of 0 or less block replies.", Number, []; + RepostsPerMinute, reposts_per_minute: i64, moderation.reposts_per_minute, "Repost rate limit", "Reposts per account per minute; values of 0 or less block reposts.", Number, []; + AccountCreationsPerIpPerDay, account_creations_per_ip_per_day: i64, moderation.account_creations_per_ip_per_day, "Registration rate limit", "Registrations per client IP per day; values of 0 or less block registration.", Number, []; + FailedLoginAttemptsPer15M, failed_login_attempts_per_15m: i64, moderation.failed_login_attempts_per_15m, "Failed login rate limit", "Failed logins per client IP per 15 minutes; values of 0 or less block login.", Number, []; + AnonymousPostsPerIpPerHour, anonymous_posts_per_ip_per_hour: i64, moderation.anonymous_posts_per_ip_per_hour, "Anonymous posting rate limit", "Anonymous posts per client IP per hour; values of 0 or less block posting.", Number, []; + TorEnabled, tor_enabled: bool, tor.enabled, "Enable onion service", "Start the embedded Arti onion service on restart.", Boolean, [serde(default = "unchecked")]; + TorOnly, tor_only: bool, tor.tor_only, "Onion access only", "Disable the public HTTP listener. Requires the onion service; ensure onion access before restarting.", Boolean, [serde(default = "unchecked")]; + OnionServiceName, onion_service_name: String, tor.onion_service_name, "Onion service identity name", "Validated local name. Changing it selects a different onion identity on restart; it does not migrate keys.", Text, []; + DisplayOnionAddress, display_onion_address: String, tor.display_onion_address, "Public onion address", "Optional 56-character v3 onion hostname. Empty uses the running onion service address.", Text, []; + BootstrapTimeoutSecs, bootstrap_timeout_secs: u64, tor.bootstrap_timeout_secs, "Onion bootstrap timeout", "Seconds allowed for Arti bootstrap.", Number, []; + MaxConcurrentStreams, max_concurrent_streams: usize, tor.max_concurrent_streams, "Concurrent onion streams", "Streams per circuit; must fit in a 32-bit unsigned integer.", Number, []; + BackupEnabled, backup_enabled: bool, backup.enabled, "Enable backups", "Allow manual backups and scheduled backups.", Boolean, [serde(default = "unchecked")]; + AutomaticEnabled, automatic_enabled: bool, backup.automatic_enabled, "Schedule automatic backups", "Run automatic backups using the configured interval.", Boolean, [serde(default = "unchecked")]; + AutomaticIntervalMinutes, automatic_interval_minutes: u64, backup.automatic_interval_minutes, "Automatic backup interval", "Minutes between backups; minimum 1. Scheduler checks once per minute.", Number, []; + RetentionKeepLast, retention_keep_last: usize, backup.retention_keep_last, "Automatic backups to keep", "Keep the newest 1–10000 automatic backups. Manual backups are unaffected.", Number, []; + RetentionMaxAgeDays, retention_max_age_days: u64, backup.retention_max_age_days, "Maximum automatic backup age", "Days, 0–3650. Set 0 to disable age-based pruning.", Number, []; + AutomaticIncludeTorKeys, automatic_include_tor_keys: bool, backup.automatic_include_tor_keys, "Include onion keys in automatic backups", "Sensitive: backups will contain private onion identity keys. Protect backup files.", Boolean, [serde(default = "unchecked")]; + CreateAdminOnFirstBoot, create_admin_on_first_boot: bool, admin.create_admin_on_first_boot, "First boot administrator setup", "Prompt for an administrator at startup only when no administrator exists.", Boolean, [serde(default = "unchecked")]; +} + +impl DeepSettingsField { + #[must_use] + pub const fn section(self) -> &'static str { + match self.toml_section().as_bytes() { + b"site" => "Site", + b"accounts" => "Accounts", + b"posts" => "Posts", + b"media" => "Media", + b"server" => "Networking & cookies", + b"moderation" => "Rate limiting", + b"tor" => "Onion service", + b"backup" => "Backups", + _ => "Administration", + } + } + + #[must_use] + pub const fn applies_live(self) -> bool { + matches!(self, Self::NsfwBlurEnabled) || matches!(self.toml_section().as_bytes(), b"backup") + } +} + +impl DeepSettingsValues { + #[must_use] + pub fn display_value(&self, field: DeepSettingsField) -> String { + let value = self.form_value(field); + match field { + DeepSettingsField::MaxTextChars + | DeepSettingsField::MinPasswordLength + | DeepSettingsField::MaxUsernameLen + | DeepSettingsField::MaxDisplayNameLen + | DeepSettingsField::MaxBioLen => format!("{value} characters"), + DeepSettingsField::PostEditWindowSeconds => format!("{value} seconds"), + _ if field.input_kind() == DeepSettingsInputKind::Size => format!("{value} MB (MiB)"), + _ => value, + } + } +} + +trait SettingsValue: Sized { + fn parse(value: &str, field: DeepSettingsField) -> anyhow::Result; + fn formatted(&self, kind: DeepSettingsInputKind) -> String; + fn copy_value(&self) -> Self; +} + +impl SettingsValue for String { + fn parse(value: &str, _field: DeepSettingsField) -> anyhow::Result { + Ok(value.to_owned()) + } + fn formatted(&self, _kind: DeepSettingsInputKind) -> String { + self.clone() + } + fn copy_value(&self) -> Self { + self.clone() + } +} +impl SettingsValue for Vec { + fn parse(value: &str, _field: DeepSettingsField) -> anyhow::Result { + Ok(value + .lines() + .map(str::trim) + .filter(|line| !line.is_empty()) + .map(str::to_owned) + .collect()) + } + fn formatted(&self, _kind: DeepSettingsInputKind) -> String { + self.join("\n") + } + fn copy_value(&self) -> Self { + self.clone() + } +} +impl SettingsValue for bool { + fn parse(value: &str, field: DeepSettingsField) -> anyhow::Result { + match value { + "true" => Ok(true), + "false" => Ok(false), + _ => anyhow::bail!("{} must be true or false", field.label()), + } + } + fn formatted(&self, _kind: DeepSettingsInputKind) -> String { + self.to_string() + } + fn copy_value(&self) -> Self { + *self + } +} +macro_rules! numeric_value { + ($($ty:ty),*) => { $(impl SettingsValue for $ty { + fn parse(value: &str, field: DeepSettingsField) -> anyhow::Result { + if field.input_kind() == DeepSettingsInputKind::Size { + return Self::try_from(parse_mib(value)?).with_context(|| format!("{} is too large", field.label())); + } + if !stringify!($ty).starts_with('i') && value.trim().starts_with('-') { + anyhow::bail!("{} must not be negative", field.label()); + } + value.trim().parse().with_context(|| format!("{} must be a whole number in range", field.label())) + } + fn formatted(&self, kind: DeepSettingsInputKind) -> String { + if kind == DeepSettingsInputKind::Size { + format_mib(u128::try_from(*self).unwrap_or_default()) + } else { self.to_string() } + } + fn copy_value(&self) -> Self { *self } + })* } +} +numeric_value!(usize, u64, u16, u8, i64); + +fn parse_mib(value: &str) -> anyhow::Result { + let value = value.trim(); + let (whole, fraction) = value.split_once('.').unwrap_or((value, "")); + if whole.is_empty() + || !whole + .bytes() + .chain(fraction.bytes()) + .all(|b| b.is_ascii_digit()) + || fraction.len() > 20 + { + anyhow::bail!("Size must be a nonnegative number of MiB with at most 20 decimal places"); + } + let whole: u64 = whole.parse().context("Size is too large")?; + let scale = 10_u128.pow(u32::try_from(fraction.len())?); + let numerator = if fraction.is_empty() { + 0 + } else { + fraction.parse::()? + } * 1_048_576; + if !numerator.is_multiple_of(scale) { + anyhow::bail!("Size must represent a whole number of bytes"); + } + let bytes = u128::from(whole) * 1_048_576 + numerator / scale; + u64::try_from(bytes).context("Size is too large") +} +fn format_mib(bytes: u128) -> String { + let whole = bytes / 1_048_576; + let remainder = bytes % 1_048_576; + if remainder == 0 { + return whole.to_string(); + } + let fraction = format!("{:020}", remainder * 5_u128.pow(20)); + format!("{whole}.{}", fraction.trim_end_matches('0')) +} + +fn unchecked() -> String { + "false".to_owned() +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeSet; + + #[test] + fn every_configuration_field_has_exactly_one_classification() { + let model = toml::Value::try_from(Settings::default()).expect("serialized config"); + let model_keys: BTreeSet<_> = model + .as_table() + .expect("config table") + .iter() + .flat_map(|(section, value)| { + value + .as_table() + .expect("section table") + .keys() + .map(move |key| format!("{section}.{key}")) + }) + .collect(); + let editable: BTreeSet<_> = DeepSettingsField::ALL + .into_iter() + .map(|field| format!("{}.{}", field.toml_section(), field.toml_key())) + .collect(); + let excluded: BTreeSet<_> = NON_WEB_SETTINGS + .iter() + .map(|(key, reason)| { + assert_ne!(reason.len(), 0); + (*key).to_string() + }) + .collect(); + assert_eq!( + editable.len(), + DeepSettingsField::ALL.len(), + "Duplicate admin mapping" + ); + assert!(editable.is_disjoint(&excluded)); + assert_eq!( + model_keys, + editable.union(&excluded).cloned().collect(), + "New config fields must be classified" + ); + let names: BTreeSet<_> = DeepSettingsField::ALL + .into_iter() + .map(DeepSettingsField::form_name) + .collect(); + assert_eq!(names.len(), editable.len()); + } + + #[test] + fn defaults_round_trip_through_every_form_field() { + let settings = Settings::default(); + let form = DeepSettingsForm::from_settings(&settings); + let values = parse_deep_settings_form(&form, &settings).expect("default form"); + assert_eq!( + toml::Value::try_from(values.apply_to(&settings)).expect("updated"), + toml::Value::try_from(settings).expect("original") + ); + } + + #[test] + fn exact_byte_limits_round_trip_without_rounding() { + for bytes in [0, 1, 65_536, 1_048_575, 1_048_577, 52_428_801, u64::MAX] { + assert_eq!( + parse_mib(&format_mib(u128::from(bytes))).expect("exact bytes"), + bytes + ); + } + assert!(parse_mib("0.00001").is_err()); + assert!(parse_mib("-1").is_err()); + assert!(parse_mib("NaN").is_err()); + } + + #[test] + fn enums_lists_optional_values_and_bounds_use_config_validation() { + let settings = Settings::default(); + let mut form = DeepSettingsForm::from_settings(&settings); + form.public_url = "https://example.org".to_owned(); + form.trusted_proxy_cidrs = "127.0.0.1/32\n::1/128\n".to_owned(); + form.vp9_deadline = "realtime".to_owned(); + let values = parse_deep_settings_form(&form, &settings).expect("valid options"); + assert_eq!(values.trusted_proxy_cidrs.len(), 2); + form.public_url.clear(); + form.display_onion_address.clear(); + form.allowed_image_mime_types.clear(); + assert!(parse_deep_settings_form(&form, &settings).is_ok()); + for bad in ["slow", "good;touch /tmp/unsafe"] { + form.vp9_deadline = bad.to_owned(); + assert!(parse_deep_settings_form(&form, &settings).is_err()); + } + for speed in super::super::VideoEncodingSpeed::ALL { + assert_eq!( + speed + .as_str() + .parse::() + .expect("enum"), + speed + ); + } + form.vp9_deadline = "good".to_owned(); + form.webp_quality = "101".to_owned(); + assert!(parse_deep_settings_form(&form, &settings).is_err()); + form.webp_quality = "100".to_owned(); + form.vp9_crf = "64".to_owned(); + assert!(parse_deep_settings_form(&form, &settings).is_err()); + form.vp9_crf = "63".to_owned(); + form.tor_only = "true".to_owned(); + assert!(parse_deep_settings_form(&form, &settings).is_err()); + form.tor_enabled = "true".to_owned(); + assert!(parse_deep_settings_form(&form, &settings).is_ok()); + } + + #[test] + fn malformed_hosts_urls_and_lists_are_rejected() { + let mut settings = Settings::default(); + for host in ["localhost; rm", "", "127.0.0.999"] { + settings.server.host = host.to_owned(); + assert!(settings.validate().is_err()); + } + settings.server.host = "127.0.0.1".to_owned(); + for url in [ + "javascript:alert(1)", + "https://user:password@example.org", + "/relative", + ] { + settings.server.public_url = url.to_owned(); + assert!(settings.validate().is_err()); + } + settings.server.public_url.clear(); + for cidr in ["10.0.0.0/33", "::/129", "127.0.0.1/"] { + settings.server.trusted_proxy_cidrs = vec![cidr.to_owned()]; + assert!(settings.validate().is_err()); + } + } + + #[test] + fn runtime_classification_is_explicit() { + for field in DeepSettingsField::ALL { + assert_eq!( + field.applies_live(), + field == DeepSettingsField::NsfwBlurEnabled || field.toml_section() == "backup" + ); + } + } + #[test] + fn listener_addresses_preserve_bracketed_ipv6_and_support_bare_ipv6() { + let mut settings = Settings::default(); + for host in ["127.0.0.1", "[::1]", "::1"] { + settings.server.host = host.to_owned(); + settings.validate().expect("valid listener"); + assert_eq!( + settings.server.listener_address().expect("listener").port(), + 8080 + ); + } + } +} diff --git a/src/identity.rs b/src/identity.rs index 34f079f..2399980 100644 --- a/src/identity.rs +++ b/src/identity.rs @@ -624,7 +624,7 @@ mod tests { let history = username_history(&pool, alice).await.expect("history"); assert_eq!(history.len(), 1); assert_eq!(history[0].username, "alice"); - assert!(!history[0].changed_at.is_empty()); + assert_ne!(history[0].changed_at.len(), 0); } #[tokio::test] @@ -808,12 +808,13 @@ mod tests { assert_eq!(holders[0].user_id, alice); assert_eq!(holders[0].username, "alice_new"); assert_eq!(holders[0].display_name, "alice"); - assert!(!holders[0].changed_at.is_empty()); - assert!( + assert_ne!(holders[0].changed_at.len(), 0); + assert_eq!( historical_username_holders(&pool, "never_used") .await .expect("empty") - .is_empty() + .len(), + 0 ); pool.call(move |conn| { @@ -822,11 +823,12 @@ mod tests { }) .await .expect("mark deleted"); - assert!( + assert_eq!( historical_username_holders(&pool, "alice") .await .expect("deleted") - .is_empty() + .len(), + 0 ); } diff --git a/src/lib.rs b/src/lib.rs index cae6a45..b4176b3 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -43,3 +43,5 @@ pub mod terminal; pub mod tor; pub mod validation; pub mod youtube; + +pub mod updates; diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 15c7f35..473c737 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -10,6 +10,8 @@ pub enum Scope { FailedLogin, Registration, AnonymousPost, + UpdateCheck, + UpdateReauthentication, } impl Scope { @@ -22,6 +24,8 @@ impl Scope { Self::FailedLogin => "failed_login", Self::Registration => "registration", Self::AnonymousPost => "anonymous_post", + Self::UpdateCheck => "update_check", + Self::UpdateReauthentication => "update_reauthentication", } } } diff --git a/src/render.rs b/src/render.rs index 7f3851b..eb6721b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -442,6 +442,29 @@ const CLIENT_BOOT_SCRIPT: &str = r#"document.documentElement.classList.add("js-e const CLIENT_SCRIPT: &str = r#"document.documentElement.classList.add("js-enabled"); +const settingsSearch = document.querySelector('#settings-search'); +if (settingsSearch) { + settingsSearch.closest(".settings-search").hidden = false; + settingsSearch.addEventListener("input", () => { + const query = settingsSearch.value.trim().toLocaleLowerCase(); + let count = 0; + document.querySelectorAll(".deep-settings-field").forEach((field) => { + field.hidden = !field.textContent.toLocaleLowerCase().includes(query); + if (!field.hidden) count += 1; + }); + document.querySelectorAll(".deep-settings-group").forEach((group) => { + group.hidden = !Array.from(group.querySelectorAll(".deep-settings-field")).some((field) => !field.hidden); + }); + document.querySelector('#settings-search-status').textContent = `${count} settings found`; + }); + document.querySelectorAll(".settings-category-nav a").forEach((link) => { + link.addEventListener("click", () => { + settingsSearch.value = ""; + settingsSearch.dispatchEvent(new Event("input")); + }); + }); +} + function cardInteractiveTarget(target) { if (!(target instanceof Element)) { return null; @@ -3308,6 +3331,7 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .username-history-list{list-style:none;margin:.35rem 0 0;padding:0;display:grid;gap:.2rem} @media (max-width:1100px){.app-shell{--shell-side:220px;--shell-max:880px;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary))}.right-rail{display:none}} @media (max-width:820px){.app-shell{grid-template-columns:minmax(0,680px)}.left-rail,.right-rail{display:none}.mobile-nav{display:flex}} +.admin-console-nav,.settings-category-nav{display:flex;flex-wrap:wrap;gap:.4rem;margin-bottom:.85rem}.admin-console-nav a,.settings-category-nav a{display:inline-flex;align-items:center;min-height:2.75rem;padding:.45rem .7rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);font-weight:750}.admin-console-nav a[aria-current=page]{background:var(--brand);color:var(--brand-text);border-color:var(--brand)}.settings-category-nav{padding:.85rem;margin:0;border-bottom:1px solid var(--border)}.settings-search,.deployment-settings{padding:.85rem}.settings-search label{font-weight:800}.settings-search input{width:100%;min-width:0}.deep-settings-group{min-width:0;scroll-margin-top:6rem}.deep-settings-field{min-width:0;overflow-wrap:anywhere}.deep-settings-field input:not([type=checkbox]),.deep-settings-field textarea,.deep-settings-field select{width:100%;max-width:100%}.deep-settings-field input[type=checkbox]{min-height:1.5rem;width:1.5rem;margin:.4rem}.setting-default,.setting-timing{display:block;margin-top:.2rem}.setting-timing{font-weight:750}.deployment-settings{border-top:1px solid var(--border);overflow-wrap:anywhere}.deployment-settings summary{min-height:2.75rem;cursor:pointer;font-weight:800}.configuration-source{font-size:.9rem;overflow-wrap:anywhere}.configuration-source p{margin:.35rem 0}.deep-settings-confirm{overflow-wrap:anywhere}.deep-settings-field input[type=number]{min-height:2.75rem;padding:.72rem .8rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text)}.deep-settings-field [aria-invalid=true]{border-color:var(--danger)}.admin-console-nav,.settings-category-nav{justify-content:flex-start}.deep-settings-field label{margin:0}.deep-settings-field:has(input[type=checkbox]){grid-template-columns:minmax(0,1fr) auto;align-items:center}.deep-settings-field:has(input[type=checkbox]) label{min-height:2.75rem;display:flex;align-items:center}.deep-settings-field:has(input[type=checkbox]) .field-help{grid-column:1/-1}.deep-settings-field[hidden],.deep-settings-group[hidden],.settings-search[hidden]{display:none} @media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;flex-wrap:wrap;width:100%;gap:.55rem}.announcement{flex-basis:100%;max-width:100%}.tor-indicator{max-width:calc(100% - 7rem);margin-left:auto}.tor-details{left:auto;right:0;max-width:calc(100vw - 1.5rem)}.site-header{position:static}nav{justify-content:flex-start}.mobile-nav{width:100%}.search-form,.inline-settings-form,.settings-grid,.deep-settings-group,.admin-user-search,.admin-user-row,.onboarding-media-row{grid-template-columns:1fr}.search-form button,.inline-settings-form button{width:100%}.composer-tools,.post-header,.profile-heading,.profile-title-row,.account-row,.settings-editor-bar,.notifications-hero{align-items:stretch;grid-template-columns:1fr;flex-direction:column}.composer-footer,.composer-media-selection{align-items:flex-start;flex-direction:column}.composer-file-input{max-width:100%}.settings-banner-preview{height:150px}.settings-picture-row{grid-template-columns:1fr;margin-top:-38px;gap:.5rem}.settings-picture-preview{width:92px;height:92px}.settings-media-controls{padding-top:0}.media-control-row{align-items:flex-start}.settings-switch-row{grid-template-columns:1fr;gap:.55rem}.settings-switch-toggle,.settings-switch-control{justify-self:start}.settings-form-actions{justify-content:stretch}.settings-form-actions button,.settings-danger-action .button-link{width:100%;justify-content:center}.settings-item-list li{align-items:stretch;flex-direction:column}.admin-user-search-actions,.admin-user-actions{align-items:stretch;flex-direction:column}.admin-user-search-actions button,.admin-user-search-actions .button-link,.admin-user-actions button{width:100%;justify-content:center}.panel dl:not(.dashboard-list){grid-template-columns:1fr}table{display:block;max-width:100%;overflow-x:auto}.author-block{align-items:flex-start}.reply-post{margin-left:.65rem;padding-left:.8rem}.reply-post::before{left:-.65rem;width:.65rem}.button-link{padding:.42rem .55rem}.counts{gap:.45rem}.page-header h1,.section-heading h1,.panel h1,.notifications-hero h1{font-size:1.25rem}.notification-row{grid-template-columns:auto minmax(0,1fr);gap:.6rem}.unread-dot{position:absolute;right:.75rem;top:.75rem;margin:0}.notification-preview{padding:.5rem}} "#; diff --git a/src/runtime.rs b/src/runtime.rs index 54a7a51..185ac31 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -315,7 +315,14 @@ impl RuntimePaths { fn restrict_dir(path: &Path) -> anyhow::Result<()> { use std::os::unix::fs::PermissionsExt as _; - fs::set_permissions(path, fs::Permissions::from_mode(0o700))?; + fs::set_permissions( + path, + fs::Permissions::from_mode(if crate::updates::managed() { + 0o770 + } else { + 0o700 + }), + )?; Ok(()) } diff --git a/src/server.rs b/src/server.rs index d45065e..9405b42 100644 --- a/src/server.rs +++ b/src/server.rs @@ -32,11 +32,21 @@ use crate::{ render, social, }; +mod update_routes; +pub use update_routes::UpdateHealth; +use update_routes::{ + admin_check_updates, admin_install_update, admin_update_status, admin_updates, + managed_update_guard, update_backup_panel, update_health, updater_script, +}; + const CSRF_TOKEN_HISTORY_LIMIT: usize = 32; #[derive(Clone)] pub struct AppState { pub pool: SqlitePool, + pub update_status: Arc>, + update_socket: Option, + configuration_write_lock: Arc>, pub settings: Settings, pub paths: RuntimePaths, pub ffmpeg: FfmpegStatus, @@ -64,6 +74,12 @@ impl AppState { let nsfw_blur_default = settings.media.nsfw_blur_enabled; Arc::new(Self { pool, + update_status: Arc::new(tokio::sync::Mutex::new(crate::updates::Status { + installed: crate::updates::VERSION.into(), + ..crate::updates::Status::default() + })), + update_socket: crate::updates::managed().then(|| PathBuf::from(crate::updates::SOCKET)), + configuration_write_lock: Arc::new(tokio::sync::Mutex::new(())), settings, paths, ffmpeg, @@ -295,7 +311,20 @@ pub fn spawn_maintenance_scheduler( const RATE_LIMIT_RETENTION_SECS: i64 = 2 * 60 * 60; const MAINTENANCE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(30); let mut first_cycle = true; + let update_socket = + crate::updates::managed().then(|| PathBuf::from(crate::updates::SOCKET)); loop { + // Upgraded startup may migrate the DB, but it must not delete media + // before health verification: rollback preserves the old references. + if !crate::updates::mutations_allowed(update_socket.as_deref()).await { + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { break; } + } + () = tokio::time::sleep(std::time::Duration::from_secs(1)) => {} + } + continue; + } match crate::account::recover_pending_media_deletions(&paths).await { Ok(0) => {} Ok(recovered) => tracing::info!( @@ -356,6 +385,7 @@ pub fn router(state: Arc) -> Router { .route("/", get(home)) .route("/assets/rustpost-boot.js", get(client_boot_script)) .route("/assets/rustpost.js", get(client_script)) + .route("/assets/rustpost-updates.js", get(updater_script)) .route("/favicon.ico", get(site_favicon)) .route("/local", get(local_redirect)) .route("/home", get(home)) @@ -416,6 +446,11 @@ pub fn router(state: Arc) -> Router { .route("/mentions", get(mention_suggestions)) .route("/search", get(search)) .route("/tags/{tag}", get(tag)) + .route("/internal/update-health", get(update_health)) + .route("/admin/updates", get(admin_updates)) + .route("/admin/updates/status", get(admin_update_status)) + .route("/admin/updates/check", post(admin_check_updates)) + .route("/admin/updates/install", post(admin_install_update)) .route("/admin", get(admin_dashboard)) .route("/admin/users", get(admin_users)) .route("/admin/users/{id}/suspend", post(admin_suspend)) @@ -482,6 +517,10 @@ pub fn router(state: Arc) -> Router { .layer(middleware::from_fn( crate::compression::response_compression, )) + .layer(middleware::from_fn_with_state( + state.clone(), + managed_update_guard, + )) .layer(TraceLayer::new_for_http()) .with_state(state) } @@ -850,6 +889,48 @@ async fn page_layout( body: &str, ) -> AppResult { let context = layout_context(state, user).await?; + let admin_body; + let body = if user.is_some_and(|user| user.is_admin) + && matches!( + title, + "Admin" + | "Site health" + | "Admin users" + | "Media jobs" + | "Deep server settings" + | "Backups" + | "Software updates" + ) { + let navigation = [ + ("/admin", "Overview", "Admin"), + ("/admin/health", "Site health", "Site health"), + ("/admin/users", "Users", "Admin users"), + ("/admin/media", "Media jobs", "Media jobs"), + ( + "/admin/deep-settings", + "Configuration", + "Deep server settings", + ), + ("/admin/backups", "Backups", "Backups"), + ("/admin/updates", "Updates", "Software updates"), + ] + .into_iter() + .fold(String::new(), |mut links, (href, label, page)| { + let current = if title == page { + " aria-current=\"page\"" + } else { + "" + }; + let _ = write!(links, r#"{label}"#); + links + }); + admin_body = format!( + r#"{body}"# + ); + &admin_body + } else { + body + }; Ok(render::layout_with_context( user, csrf, @@ -4813,7 +4894,7 @@ async fn admin_dashboard( "Manage site health, users, media jobs, settings, and backups." ), saved_notice, - r#"
Site healthUsersMedia jobsDeep server settingsBackups
"#, + r#"
Site healthUsersMedia jobsDeep server settingsBackupsSoftware updates
"#, instance_panels, favicon_panel ); @@ -5383,10 +5464,14 @@ async fn admin_deep_settings( ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let _configuration_guard = state.configuration_write_lock.lock().await; let current = load_deep_settings(&state)?; let values = admin::DeepSettingsValues::from_settings(¤t); let notice = if query.saved.is_some() { - Some(("success", "Settings saved successfully")) + Some(( + "success", + "Settings saved successfully. Restart required for startup settings; blur applies immediately and backup policy takes effect on the next check.", + )) } else if query.discarded.is_some() { Some(("info", "Changes discarded.")) } else { @@ -5404,6 +5489,7 @@ async fn admin_deep_settings_update( let user = require_admin(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let _configuration_guard = state.configuration_write_lock.lock().await; let current = load_deep_settings(&state)?; if form.intent.as_deref() == Some("discard") { let values = admin::DeepSettingsValues::from_settings(¤t); @@ -5411,12 +5497,34 @@ async fn admin_deep_settings_update( return deep_settings_html(&state, &user, &csrf, &body).await; } + if form.revision.as_ref().is_none_or(|revision| { + configuration_revision(&state).map_or(true, |current| ¤t != revision) + }) { + let values = admin::DeepSettingsValues::from_settings(¤t); + let body = render_deep_settings_submission( + &csrf, + &values, + Some(( + "error", + "Settings changed since this form was opened. Reload the page and review the latest values before saving.", + )), + None, + ); + return deep_settings_html(&state, &user, &csrf, &body).await; + } + if !matches!(form.intent.as_deref(), Some("preview" | "confirm")) { + return Err(AppError::BadRequest("Unknown settings action".to_owned())); + } let values = match admin::parse_deep_settings_form(&form, ¤t) { Ok(values) => values, Err(err) => { let fallback = admin::DeepSettingsValues::from_settings(¤t); - let body = - render_deep_settings_form(&csrf, &fallback, Some(("error", &err.to_string()))); + let body = render_deep_settings_submission( + &csrf, + &fallback, + Some(("error", &err.to_string())), + Some(&form), + ); return deep_settings_html(&state, &user, &csrf, &body).await; } }; @@ -5458,7 +5566,10 @@ async fn admin_deep_settings_update( let body = render_deep_settings_form( &csrf, &values, - Some(("success", "Settings saved successfully")), + Some(( + "success", + "Settings saved successfully. Restart required for startup settings; blur applies immediately and backup policy takes effect on the next check.", + )), ); return deep_settings_html(&state, &user, &csrf, &body).await; } @@ -5467,14 +5578,81 @@ async fn admin_deep_settings_update( deep_settings_html(&state, &user, &csrf, &body).await } +fn configuration_revision(state: &AppState) -> AppResult { + use sha2::{Digest as _, Sha256}; + let raw = std::fs::read(&state.paths.settings_path)?; + Ok(Sha256::digest(&raw) + .iter() + .fold(String::new(), |mut output, byte| { + let _ = write!(output, "{byte:02x}"); + output + })) +} + async fn deep_settings_html( state: &AppState, user: &CurrentUser, csrf: &str, body: &str, ) -> AppResult> { + let revision = configuration_revision(state)?; + let body = body.replace(r#""#, + field.form_name() + ); + let effective = format!( + "{marker}Restart pending. Running value: {}. ", + html_escape::encode_text(&running.form_value(field)) + ); + body = body.replace(&marker, &effective); + } + } + for (key, value) in [ + ("media.ffmpeg_path", settings.media.ffmpeg_path.as_str()), + ("tor.data_dir", settings.tor.data_dir.as_str()), + ("backup.backup_dir", settings.backup.backup_dir.as_str()), + ( + "tor.include_tor_keys_in_backups_by_default", + if settings.tor.include_tor_keys_in_backups_by_default { + "true" + } else { + "false" + }, + ), + ] { + let marker = format!("{key}:"); + body = body.replace( + &marker, + &format!( + "{marker} Configured value: {}. ", + html_escape::encode_text(value) + ), + ); + } + let source = format!( + r#"

Source: {}. No environment or CLI value overrides apply to these settings. CLI options select the configuration file and data directory.

Stored values are shown below. Startup settings keep their running values until restart.

"#, + html_escape::encode_text(&state.paths.settings_path.display().to_string()) + ); Ok(Html( - page_layout(state, Some(user), Some(csrf), "Deep server settings", body).await?, + page_layout( + state, + Some(user), + Some(csrf), + "Deep server settings", + &body.replace( + r#"