diff --git a/.dockerignore b/.dockerignore
index 5c1d460..78196ed 100644
--- a/.dockerignore
+++ b/.dockerignore
@@ -6,3 +6,18 @@ queue-demo
*.tar.gz
*.zip
*.sha256
+
+# Runtime data and local build artifacts
+target-*
+/app-data
+/db
+/uploads
+/backups
+/logs
+/tmp
+/tor
+.env
+.env.*
+*.sqlite*
+*.db*
+*.tar
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
new file mode 100644
index 0000000..bebeb24
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -0,0 +1,23 @@
+name: Report an issue
+description: Tell us what went wrong.
+body:
+ - type: input
+ id: version
+ attributes:
+ label: Version
+ placeholder: "1.1.0"
+ validations:
+ required: true
+ - type: input
+ id: platform
+ attributes:
+ label: Platform
+ placeholder: "macOS Intel, Linux ARM64, Windows"
+ validations:
+ required: true
+ - type: textarea
+ id: issue
+ attributes:
+ label: What is the issue?
+ validations:
+ required: true
diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml
new file mode 100644
index 0000000..a705e07
--- /dev/null
+++ b/.github/ISSUE_TEMPLATE/config.yml
@@ -0,0 +1,5 @@
+blank_issues_enabled: true
+contact_links:
+ - name: Security information
+ url: https://github.com/csd113/RustPost/security/advisories
+ about: Do not post secrets or sensitive security details in public issues.
diff --git a/.github/workflows/audit.yml b/.github/workflows/audit.yml
new file mode 100644
index 0000000..6fe0624
--- /dev/null
+++ b/.github/workflows/audit.yml
@@ -0,0 +1,57 @@
+name: Dependency Audit
+on:
+ pull_request:
+ branches: [main, dev]
+ paths: [Cargo.toml, Cargo.lock, .github/workflows/audit.yml]
+ push:
+ branches: [main, dev]
+ paths: [Cargo.toml, Cargo.lock, .github/workflows/audit.yml]
+ schedule:
+ - cron: "23 6 * * 1"
+ workflow_dispatch:
+permissions:
+ contents: read
+concurrency:
+ group: audit-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
+jobs:
+ audit:
+ name: Dependency advisory report
+ runs-on: ubuntu-24.04
+ timeout-minutes: 15
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+ with:
+ toolchain: 1.99.1
+ - name: Install cargo-audit
+ uses: taiki-e/install-action@c509219cf42fba86913069efd0ade2458c59337b # v2.90.1
+ with:
+ tool: cargo-audit@0.22.2
+ fallback: none
+ - name: Report all dependency advisories
+ id: audit
+ # Report-only policy: keep upstream blockers visible, without an ignore list.
+ continue-on-error: true
+ run: cargo audit --file Cargo.lock --format json > audit.json
+ - name: Verify report and summarize findings
+ env:
+ AUDIT_OUTCOME: ${{ steps.audit.outcome }}
+ run: |
+ jq -e '.lockfile["dependency-count"] > 0' audit.json > /dev/null
+ echo "Dependency audit: $(jq -r '.vulnerabilities.count' audit.json) vulnerability findings; full report attached." >> "$GITHUB_STEP_SUMMARY"
+ if [ "$AUDIT_OUTCOME" != success ]; then
+ echo "::warning::Dependency advisories require review; see the full audit report."
+ fi
+ - name: Preserve full advisory report
+ if: always()
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
+ with:
+ name: dependency-advisories
+ path: audit.json
+ if-no-files-found: error
+ retention-days: 7
diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
index f2f801b..d6c780a 100644
--- a/.github/workflows/ci.yml
+++ b/.github/workflows/ci.yml
@@ -1,97 +1,103 @@
name: CI
-
on:
pull_request:
+ branches: [main, dev]
push:
- branches:
- - main
-
+ branches: [main, dev]
+ merge_group:
+ workflow_dispatch:
permissions:
contents: read
-
+concurrency:
+ group: ci-${{ github.event_name }}-${{ github.event.pull_request.number || github.ref }}
+ cancel-in-progress: true
env:
CARGO_TERM_COLOR: always
-
+ CARGO_INCREMENTAL: 0
jobs:
- fmt:
- name: Format
+ quality:
+ name: Quality
runs-on: ubuntu-24.04
+ timeout-minutes: 25
steps:
- name: Checkout
- uses: actions/checkout@v4
- - name: Install Rust 1.91
- uses: dtolnay/rust-toolchain@1.91.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- components: rustfmt
- - name: Check formatting
- run: cargo fmt --all --check
-
- clippy:
- name: Clippy
- runs-on: ubuntu-24.04
- 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
+ persist-credentials: false
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
with:
- components: clippy
- - name: Cache Cargo
- uses: Swatinem/rust-cache@v2
- with:
- cache-bin: false
- - name: Run Clippy
- run: cargo clippy --workspace --all-targets --all-features -- -D warnings
-
- strict-clippy:
- name: Strict Clippy
- runs-on: ubuntu-24.04
- 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
- with:
- components: clippy
- - name: Cache Cargo
- uses: Swatinem/rust-cache@v2
+ toolchain: 1.99.1
+ components: clippy, rustfmt
+ - name: Cache Rust artifacts
+ uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
cache-bin: false
+ - name: Check formatting
+ run: cargo fmt --all --check
- 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
-
- test:
- name: Test (${{ matrix.name }})
+ run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo
+ - name: Build API documentation
+ run: cargo doc --locked --workspace --all-features --no-deps
+ env:
+ RUSTDOCFLAGS: -D warnings
+ build:
+ name: Build (${{ matrix.asset_suffix }})
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 35
strategy:
fail-fast: false
matrix:
include:
- - name: linux-x86_64
- os: ubuntu-24.04
- - name: linux-aarch64
- os: ubuntu-24.04-arm
- - name: macos-aarch64
- os: macos-14
- - name: windows-x86_64
- os: windows-2022
- runs-on: ${{ matrix.os }}
+ - target: x86_64-apple-darwin
+ runner: macos-15-intel
+ binary_suffix: ""
+ asset_suffix: macos-intel
+ - target: aarch64-apple-darwin
+ runner: macos-15
+ binary_suffix: ""
+ asset_suffix: macos-apple-silicon
+ - target: x86_64-unknown-linux-gnu
+ runner: ubuntu-24.04
+ binary_suffix: ""
+ asset_suffix: linux-x86_64
+ - target: aarch64-unknown-linux-gnu
+ runner: ubuntu-24.04-arm
+ binary_suffix: ""
+ asset_suffix: linux-arm64
+ - target: x86_64-pc-windows-msvc
+ runner: windows-latest
+ binary_suffix: ".exe"
+ asset_suffix: windows-x86_64
steps:
- name: Checkout
- uses: actions/checkout@v4
- - 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: Cache Cargo
- uses: Swatinem/rust-cache@v2
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+ with:
+ toolchain: 1.99.1
+ targets: ${{ matrix.target }}
+ - name: Cache Rust artifacts
+ uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
+ key: ${{ matrix.target }}
cache-bin: false
- - name: Run tests
- run: cargo test --workspace --all-features
- name: Build CLI
- run: cargo build --workspace --all-features --bin rustpost-cli
+ run: cargo build --locked --workspace --all-features --target ${{ matrix.target }} --bin rustpost-cli
+ - name: Run native terminal and CLI regressions
+ run: cargo test --locked --workspace --all-features --target ${{ matrix.target }}
+ - name: Verify native executable and architecture
+ shell: bash
+ env:
+ TARGET: ${{ matrix.target }}
+ SUFFIX: ${{ matrix.binary_suffix }}
+ run: |
+ set -euo pipefail
+ binary="target/${TARGET}/debug/rustpost-cli${SUFFIX}"
+ "$binary" --version
+ if [[ "$TARGET" == *apple-darwin ]]; then
+ if [[ "$TARGET" == x86_64-* ]]; then arch=x86_64; else arch=arm64; fi
+ lipo "$binary" -verify_arch "$arch"
+ fi
diff --git a/.github/workflows/container.yml b/.github/workflows/container.yml
index 0c818b2..84502a0 100644
--- a/.github/workflows/container.yml
+++ b/.github/workflows/container.yml
@@ -1,15 +1,32 @@
name: Container
on:
+ push:
+ tags: ['v*']
workflow_dispatch:
+ inputs:
+ tag:
+ description: Published source tag to build (for example v1.1.0)
+ required: true
+ type: string
+
+env:
+ RUSTPOST_RELEASE_TAG: ${{ inputs.tag || github.ref_name }}
permissions:
contents: read
- packages: write
+
+concurrency:
+ group: container-${{ inputs.tag || github.ref_name }}
+ cancel-in-progress: false
jobs:
build:
name: Build ${{ matrix.arch }}
+ timeout-minutes: 45
+ permissions:
+ contents: read
+ packages: write
strategy:
fail-fast: false
matrix:
@@ -20,42 +37,52 @@ jobs:
runner: ubuntu-24.04-arm
runs-on: ${{ matrix.runner }}
steps:
- - uses: actions/checkout@v4
- - name: Verify v1.0.0 application source
+ - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ 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
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ 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@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
+ - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- - uses: docker/build-push-action@v6
+ - uses: docker/build-push-action@c3c9e263c25d99ce0380d002d59b67737d91b0dc # v7.4.0
with:
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:
name: Publish version and latest
+ timeout-minutes: 10
+ permissions:
+ contents: read
+ packages: write
needs: build
runs-on: ubuntu-24.04
steps:
- - uses: docker/setup-buildx-action@v3
- - uses: docker/login-action@v3
+ - uses: docker/setup-buildx-action@f87e5991a6d7451dcb8d9637bfbc97413f497069 # v4.4.1
+ - uses: docker/login-action@81c6f93d4bc991e510b342a00b419b3bb4efc5c6 # v4.7.0
with:
registry: ghcr.io
username: ${{ github.actor }}
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..e69184a 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -1,103 +1,210 @@
name: Release
-
on:
push:
- tags:
- - 'v*'
-
+ tags: ["v*"]
permissions:
- contents: write
-
+ contents: read
+concurrency:
+ group: release-${{ github.ref }}
+ cancel-in-progress: false
env:
CARGO_TERM_COLOR: always
-
+ CARGO_INCREMENTAL: 0
jobs:
- build-unix:
- name: Build ${{ matrix.name }}
+ release-validation:
+ name: Release validation
+ runs-on: ubuntu-24.04
+ timeout-minutes: 30
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+ with:
+ toolchain: 1.99.1
+ components: clippy, rustfmt
+ - name: Cache Rust artifacts
+ uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
+ with:
+ cache-bin: false
+ - name: Check formatting
+ run: cargo fmt --all --check
+ - name: Run strict Clippy
+ run: cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo
+ - name: Build API documentation
+ run: cargo doc --locked --workspace --all-features --no-deps
+ env:
+ RUSTDOCFLAGS: -D warnings
+ - name: Verify release tag matches package version
+ run: |
+ version="$(cargo metadata --locked --no-deps --format-version 1 | jq -er '.packages[] | select(.name == "rustpost") | .version')"
+ test "$GITHUB_REF_NAME" = "v$version"
+ build:
+ name: Build (${{ matrix.asset_suffix }})
+ needs: release-validation
+ runs-on: ${{ matrix.runner }}
+ timeout-minutes: 45
strategy:
fail-fast: false
matrix:
include:
- - name: macos-aarch64
- os: macos-14
- target: aarch64-apple-darwin
- archive: rustpost-macos-aarch64.tar.gz
- - name: linux-x86_64
- os: ubuntu-24.04
- target: x86_64-unknown-linux-gnu
- archive: rustpost-linux-x86_64.tar.gz
- - name: linux-aarch64
- os: ubuntu-24.04-arm
- target: aarch64-unknown-linux-gnu
- archive: rustpost-linux-aarch64.tar.gz
- runs-on: ${{ matrix.os }}
+ - target: x86_64-apple-darwin
+ runner: macos-15-intel
+ binary_suffix: ""
+ asset_suffix: macos-intel
+ - target: aarch64-apple-darwin
+ runner: macos-15
+ binary_suffix: ""
+ asset_suffix: macos-apple-silicon
+ - target: x86_64-unknown-linux-gnu
+ runner: ubuntu-24.04
+ binary_suffix: ""
+ asset_suffix: linux-x86_64
+ - target: aarch64-unknown-linux-gnu
+ runner: ubuntu-24.04-arm
+ binary_suffix: ""
+ asset_suffix: linux-arm64
+ - target: x86_64-pc-windows-msvc
+ runner: windows-latest
+ binary_suffix: ".exe"
+ asset_suffix: windows-x86_64
steps:
- name: Checkout
- uses: actions/checkout@v4
- - 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
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
+ persist-credentials: false
+ - name: Install Rust toolchain
+ uses: dtolnay/rust-toolchain@b725eda525362e7cf34af9a7243cac1e3f01a7ec # stable
+ with:
+ toolchain: 1.99.1
targets: ${{ matrix.target }}
- - name: Cache Cargo
- uses: Swatinem/rust-cache@v2
+ - name: Cache Rust artifacts
+ uses: Swatinem/rust-cache@d1527c8f67ef9af357290475d4af4b0fab8f12e0 # v2.9.3
with:
+ key: ${{ matrix.target }}
cache-bin: false
- name: Build release binary
- run: cargo build --release --locked --target ${{ matrix.target }} --bin rustpost-cli
- - name: Package archive
+ run: cargo build --locked --release --target ${{ matrix.target }} --bin rustpost-cli
+ - name: Verify native executable and architecture
+ shell: bash
+ env:
+ TARGET: ${{ matrix.target }}
+ SUFFIX: ${{ matrix.binary_suffix }}
run: |
set -euo pipefail
- staging="dist/${{ matrix.name }}/rustpost"
- mkdir -p "$staging"
- cp "target/${{ matrix.target }}/release/rustpost-cli" "$staging/"
- 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
+ binary="target/${TARGET}/release/rustpost-cli${SUFFIX}"
+ "$binary" --version
+ if [[ "$TARGET" == *apple-darwin ]]; then
+ if [[ "$TARGET" == x86_64-* ]]; then arch=x86_64; else arch=arm64; fi
+ lipo "$binary" -verify_arch "$arch"
+ fi
+ - name: Package release archive
+ shell: bash
+ env:
+ TARGET: ${{ matrix.target }}
+ BINARY_SUFFIX: ${{ matrix.binary_suffix }}
+ ASSET_SUFFIX: ${{ matrix.asset_suffix }}
+ run: |
+ python3 - <<'PYTHON'
+ import os, pathlib, re, shutil, subprocess, zipfile
+ tag = os.environ["GITHUB_REF_NAME"]
+ suffix = os.environ["ASSET_SUFFIX"]
+ if not re.fullmatch(r"v[0-9A-Za-z._-]+", tag) or not re.fullmatch(r"[0-9A-Za-z._-]+", suffix):
+ raise SystemExit("unsafe release archive name")
+ executable = "rustpost-cli" + os.environ["BINARY_SUFFIX"]
+ binary = pathlib.Path("target") / os.environ["TARGET"] / "release" / executable
+ if subprocess.check_output([str(binary), "--version"], text=True).strip() != "rustpost-cli " + tag[1:]:
+ raise SystemExit("release executable version mismatch")
+ basename = f"rustpost-cli-{tag}-{suffix}"
+ package = pathlib.Path("dist/package") / basename
+ package.mkdir(parents=True)
+ for source in [binary, pathlib.Path("README.md"), pathlib.Path("LICENSE"), pathlib.Path("CHANGELOG.md")]:
+ shutil.copy2(source, package / source.name)
+ if os.name != "nt":
+ (package / executable).chmod(0o755)
+ with zipfile.ZipFile(pathlib.Path("dist") / (basename + ".zip"), "w", zipfile.ZIP_DEFLATED, compresslevel=9) as archive:
+ for source in sorted(package.iterdir()):
+ archive.write(source, source.relative_to(package.parent))
+ PYTHON
+ - name: Upload archive
+ uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
- files: |
- dist/${{ matrix.archive }}
- dist/${{ matrix.archive }}.sha256
-
- build-windows:
- name: Build windows-x86_64
- runs-on: windows-2022
+ name: rustpost-cli-${{ matrix.asset_suffix }}
+ path: dist/*.zip
+ if-no-files-found: error
+ retention-days: 7
+ release:
+ name: Prepare GitHub Release
+ needs: build
+ runs-on: ubuntu-24.04
+ timeout-minutes: 10
+ permissions:
+ contents: write
steps:
- - name: Checkout
- uses: actions/checkout@v4
- - name: Install Rust 1.91
- uses: dtolnay/rust-toolchain@1.91.0
+ - name: Checkout changelog
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
- targets: x86_64-pc-windows-msvc
- - name: Cache Cargo
- uses: Swatinem/rust-cache@v2
+ persist-credentials: false
+ - name: Download all platform archives
+ uses: actions/download-artifact@9000827ccba6bdab643e8b6fd33ac0654aef8333 # v8.0.2
with:
- cache-bin: false
- - name: Build release binary
- run: cargo build --release --locked --target x86_64-pc-windows-msvc --bin rustpost-cli
- - name: Package archive
- shell: pwsh
+ pattern: rustpost-cli-*
+ path: release-assets
+ merge-multiple: true
+ - name: Verify complete assets and checksum manifest
run: |
- $ErrorActionPreference = "Stop"
- $staging = "dist/windows-x86_64/rustpost"
- New-Item -ItemType Directory -Force -Path $staging | Out-Null
- 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 }
- if (Test-Path "NOTICE") { Copy-Item "NOTICE" $staging }
- 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
- with:
- files: |
- dist/rustpost-windows-x86_64.zip
- dist/rustpost-windows-x86_64.zip.sha256
+ set -euo pipefail
+ cd release-assets
+ shopt -s nullglob
+ archives=( *.zip )
+ expected=(linux-arm64 linux-x86_64 macos-apple-silicon macos-intel windows-x86_64)
+ test "${#archives[@]}" -eq "${#expected[@]}"
+ for suffix in "${expected[@]}"; do
+ test -s "rustpost-cli-${GITHUB_REF_NAME}-${suffix}.zip"
+ done
+ sha256sum --binary "${archives[@]}" > SHA256SUMS.txt
+ sha256sum --check --strict SHA256SUMS.txt
+ - name: Prepare release notes
+ run: |
+ python3 - <<'PYTHON'
+ import os, pathlib
+ version = os.environ["GITHUB_REF_NAME"]
+ sections = pathlib.Path("CHANGELOG.md").read_text().split("\n## ")[1:]
+ matching = [section for section in sections if section.splitlines()[0].split(" - ")[0].split(" — ")[0] == version]
+ if not matching:
+ matching = [section for section in sections if section.splitlines()[0] == "Unreleased"]
+ if len(matching) != 1:
+ raise SystemExit("expected one changelog entry")
+ notes = matching[0].split("\n", 1)[1].strip()
+ if not notes:
+ raise SystemExit("release notes are empty")
+ pathlib.Path("RELEASE_NOTES.md").write_text(notes + "\n")
+ PYTHON
+ - name: Prepare draft release without changing published releases
+ env:
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ run: |
+ set -euo pipefail
+ python3 - <<'PYTHON'
+ import json, os, urllib.error, urllib.parse, urllib.request
+ tag = urllib.parse.quote(os.environ["GITHUB_REF_NAME"], safe="")
+ url = "https://api.github.com/repos/" + os.environ["GITHUB_REPOSITORY"] + "/releases/tags/" + tag
+ request = urllib.request.Request(url, headers={"Authorization": "Bearer " + os.environ["GH_TOKEN"], "Accept": "application/vnd.github+json"})
+ try:
+ with urllib.request.urlopen(request, timeout=20) as response:
+ release = json.load(response)
+ except urllib.error.HTTPError as error:
+ if error.code != 404:
+ raise SystemExit("cannot verify existing release") from None
+ else:
+ if release.get("draft") is not True:
+ raise SystemExit("refusing to modify a published release")
+ PYTHON
+ if ! gh release view "$GITHUB_REF_NAME" --json isDraft --jq .isDraft > /dev/null; then
+ flags=()
+ if [[ "$GITHUB_REF_NAME" == *-* ]]; then flags+=(--prerelease); fi
+ gh release create "$GITHUB_REF_NAME" --draft --verify-tag --title "$GITHUB_REF_NAME" --notes-file RELEASE_NOTES.md "${flags[@]}"
+ fi
+ gh release upload "$GITHUB_REF_NAME" release-assets/*.zip release-assets/SHA256SUMS.txt --clobber
diff --git a/.gitignore b/.gitignore
index 2d78a84..2723d57 100644
--- a/.gitignore
+++ b/.gitignore
@@ -13,29 +13,6 @@ target
# Contains mutation testing data
**/mutants.out*/
-# Generated by Playwright and browser QA runs
-node_modules/
-.playwright-cli/
-playwright-report/
-test-results/
-output/playwright/
-playwright/.cache/
-screenshots/
-videos/
-*.webm
-*.mp4
-*.zip
-trace.zip
-*.trace
-*.har
-tests/*
-tests/playwright/
-tests/playwright/*
-tests/playwright/nsfw-media.mjs
-package-lock.json
-package.json
-playwright.config.ts
-
# RustRover
# JetBrains specific template is maintained in a separate JetBrains.gitignore that can
# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore
@@ -48,14 +25,14 @@ app-data/
rustpost-data/
rustpost-data/**/*
!rustpost-data/.gitkeep
-tmp/
-tmp/**/*
-db/
-db/**/*
-backups/
-logs/
-media/
-media/**/*
+/tmp/
+/tmp/**/*
+/db/
+/db/**/*
+/backups/
+/logs/
+/media/
+/media/**/*
*.sqlite3
*.sqlite3-*
*.sqlite
@@ -67,8 +44,8 @@ app.sqlite3*
*.log
*.tmp
*.upload
-uploads/
-uploads/**/*
+/uploads/
+/uploads/**/*
# macOS
.DS_Store
@@ -76,7 +53,3 @@ uploads/**/*
# Local Codex guidance (intentionally not published)
/rules.md
-/tests
-!/tests/
-!/tests/playwright/
-/tests/playwright/*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index d0e0b99..f909adc 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,5 +1,39 @@
# Changelog
+For installation, start with [Getting started](docs/getting-started.md). Entries below record changes for their own versions; use the [current guides](docs/README.md) for current behavior.
+
+## v1.1.0 - Operations, safety, and easier setup
+
+### Running and updating your site
+
+- Add a live terminal console with six scrollable views: Overview, Tasks, Content, Logs, System, and Configuration. Plain/off modes, ASCII output, freshness indicators, and graceful shutdown controls support different terminals.
+- Protect interactive administrator passwords with hidden entry and confirmation. Add bounded `--password-stdin` input for administrator creation and password resets.
+- Rewrite the guides around download, first run, everyday use, and administration, with separate configuration/developer references and preserved screenshots and licensing.
+- Replace automatic installation with a manual, read-only **Admin → Software updates** check that links to a newer published stable GitHub release. Installation remains a backup/stop/replace/start operation; remove the former updater service, IPC, signing/package tools, and deployment permissions.
+- Prepare five versioned native CLI ZIPs for Linux x86_64/ARM64, Intel/Apple silicon Mac, and Windows x86_64, with a shared `SHA256SUMS.txt` and a draft GitHub release. ZIPs contain the executable, README, LICENSE, and CHANGELOG; native downloads do not bundle `ffmpeg` or the full online guides. The separate tagged container workflow remains available.
+
+### Backups, requests, and account safety
+
+- Publish backups atomically with streaming checksums, online SQLite snapshots, bounded archive validation, and safe retention. Restore requires the server to be stopped, validates staged content before installation, and preserves recovery data when rollback cannot complete.
+- Keep session-bound CSRF tokens stable, validate origins/proxy metadata/cookies consistently, and delay multipart asset publication until validation succeeds.
+- Charge account-import quota only after complete multipart CSRF validation, with an atomic quota check/record. Submissions rejected for missing or invalid form tokens no longer consume the account's import quota; staged uploads retain bounded streaming and cleanup guards.
+- Require a CSRF-protected POST to prepare account deletion. GET/HEAD confirmation navigation cannot create or replace a deletion intent; the no-JavaScript warning/confirmation flow and final confirmation checks remain available.
+- Bound media processing, uploads, password work, database queues, and Tor forwarding. Fix cancellation cleanup, make profile/onboarding updates atomic, and preserve access to original video downloads. Drain admitted HTTP requests on SIGTERM.
+
+### Build and compatibility
+
+- Upgrade the compiler/minimum supported Rust version to **1.99.1** and current dependencies, including **Arti 0.48.0**, retaining **rusqlite 0.40.2** for a single compatible SQLite linkage.
+- Split large source files into subsystem modules and extract embedded assets. Remove the former broad application/Playwright suites and runners; retain the current terminal/CLI and focused CSRF route regressions.
+- Simplify CI and issue forms, validate native builds/tests on all five platforms, and remove the unused systemd service example.
+- Bump the package, lockfile entry, container version metadata, and current usage examples to **1.1.0**. CLI output, console display, update comparison/user agent, and backup manifests derive the app version from Cargo. Database schema **4**, account archive format **1**, and backup format **1** are unchanged.
+
+### Known limits and upgrade notes
+
+- Dependency Audit is report-only: success means a valid advisory report was produced, not that dependencies are free of advisories. Upstream **RUSTSEC-2023-0071** (`rsa 0.9.10`) and **RUSTSEC-2026-0387** (`crypto-bigint 0.5.5`) remain open, with maintenance warnings for transitive `bincode 2.0.1` and `paste 1.0.15`; inspect the current report before deployment.
+- Keep a full-site backup before upgrading from v1.0.0. Browser/live restore and automatic installation are no longer available; use [offline restore](docs/operator-guide.md#restore-with-the-server-stopped) and [manual updates](docs/software-updates.md).
+- RustPost remains a single-community service without federation. Search/reporting are basic, broader accessibility review is outstanding, and synchronous media conversion can delay uploads. Optional `ffmpeg` improves conversion/thumbnail support; original-format playback depends on the browser.
+- Console/log retention is bounded and best effort. Counts can be stale or unavailable, filesystem calls have no hard deadline, and there is no hard whole-process shutdown deadline for stuck filesystem operations. Displayed onion status does not prove external Tor reachability; native interactive Windows and SSH/tmux behavior still need separate validation.
+
## v1.0.0 - First Release
- Published a Linux amd64/arm64 container image as `ghcr.io/csd113/rustpost:1.0.0` and `ghcr.io/csd113/rustpost:latest`, with persistent state under `/data` and bundled `ffmpeg`.
diff --git a/Cargo.lock b/Cargo.lock
index d7eb54f..413e789 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -10,9 +10,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa"
[[package]]
name = "aes"
-version = "0.9.3"
+version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32"
+checksum = "fc0fc36d7440d015b45468ba08a21e80e056ebbd014c611c6298bb7503e04bd1"
dependencies = [
"cipher 0.5.2",
"cpubits",
@@ -29,6 +29,12 @@ dependencies = [
"memchr",
]
+[[package]]
+name = "allocator-api2"
+version = "0.2.21"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
+
[[package]]
name = "amplify"
version = "4.9.0"
@@ -140,15 +146,24 @@ version = "1.0.104"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
+[[package]]
+name = "approx"
+version = "0.5.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cab112f0a86d568ea0e627cc1d6be74a1e9cd55214684db5561995f6dad897c6"
+dependencies = [
+ "num-traits",
+]
+
[[package]]
name = "argon2"
-version = "0.5.3"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
+checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
dependencies = [
"base64ct",
"blake2",
- "cpufeatures 0.2.17",
+ "cpufeatures 0.3.1",
"password-hash",
]
@@ -160,9 +175,9 @@ checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56"
[[package]]
name = "arti-client"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097"
+checksum = "5bb17a5096e30f2b2bf9f7493ff7577ac66ed7059b8e435658a2884546afe70c"
dependencies = [
"async-trait",
"cfg-if",
@@ -256,17 +271,11 @@ dependencies = [
"syn 2.0.119",
]
-[[package]]
-name = "assert_matches"
-version = "1.5.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9"
-
[[package]]
name = "async-compression"
-version = "0.4.48"
+version = "0.4.52"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd"
+checksum = "6ad47a740fb906fffdd891e8b6d5fde2511600f7bfaebd68939fa08a14999459"
dependencies = [
"compression-codecs",
"compression-core",
@@ -282,7 +291,7 @@ checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -453,11 +462,11 @@ dependencies = [
[[package]]
name = "blake2"
-version = "0.10.6"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe"
+checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
dependencies = [
- "digest 0.10.7",
+ "digest 0.11.3",
]
[[package]]
@@ -555,15 +564,24 @@ dependencies = [
[[package]]
name = "caret"
-version = "0.10.1"
+version = "0.11.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c1965c458dd0ba78a59bb3def3162ac8614e1fc13445c395a0449bd6f2f54a3c"
+
+[[package]]
+name = "castaway"
+version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a"
+checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a"
+dependencies = [
+ "rustversion",
+]
[[package]]
name = "cc"
-version = "1.5.1"
+version = "1.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040"
+checksum = "375989decec1a0fa424442bed520fa12ede43a48475ce8c7417d2c69b32b5f6b"
dependencies = [
"find-msvc-tools",
"shlex",
@@ -629,7 +647,7 @@ checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c"
dependencies = [
"block-buffer 0.12.1",
"crypto-common 0.2.2",
- "inout 0.2.2",
+ "inout 0.2.5",
"zeroize",
]
@@ -664,7 +682,7 @@ dependencies = [
"heck",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -702,11 +720,25 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
+[[package]]
+name = "compact_str"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "9dfdd1c2274d9aa354115b09dc9a901d6c5576818cdf70d14cae2bdb47df00ab"
+dependencies = [
+ "castaway",
+ "cfg-if",
+ "itoa",
+ "rustversion",
+ "ryu",
+ "static_assertions",
+]
+
[[package]]
name = "compression-codecs"
-version = "0.4.43"
+version = "0.4.47"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9"
+checksum = "84f840cff84d10085260e460023934d0af699e71212b09b2a41452e8bd9cda43"
dependencies = [
"compression-core",
"flate2",
@@ -714,9 +746,9 @@ dependencies = [
[[package]]
name = "compression-core"
-version = "0.4.33"
+version = "0.4.34"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414"
+checksum = "2ebdda89edc534bf8d5dd2857562633b6495557e5846552ade994b99ed13c36a"
[[package]]
name = "const-oid"
@@ -786,9 +818,9 @@ dependencies = [
[[package]]
name = "crc32fast"
-version = "1.5.2"
+version = "1.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78"
+checksum = "5f97d3416d5337d15c4b0d78c8c91df922ca143f57ec20e2d1c188333047ef04"
dependencies = [
"cfg-if",
]
@@ -808,6 +840,33 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
+[[package]]
+name = "crossterm"
+version = "0.29.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b"
+dependencies = [
+ "bitflags 2.13.2",
+ "crossterm_winapi",
+ "derive_more",
+ "document-features",
+ "mio",
+ "parking_lot",
+ "rustix",
+ "signal-hook",
+ "signal-hook-mio",
+ "winapi",
+]
+
+[[package]]
+name = "crossterm_winapi"
+version = "0.9.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "acdd7c62a3665c7f6830a51635d9ac9b23ed385797f70a83bb8bafe9c572ab2b"
+dependencies = [
+ "winapi",
+]
+
[[package]]
name = "crypto-bigint"
version = "0.5.5"
@@ -837,6 +896,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
dependencies = [
"hybrid-array",
+ "rand_core 0.10.1",
]
[[package]]
@@ -850,24 +910,25 @@ dependencies = [
[[package]]
name = "ctutils"
-version = "0.4.2"
+version = "0.4.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
+checksum = "261af4dc7a06ea8a527a8717d8cbba3417530d6ea14e1edb3de6b67806be474f"
dependencies = [
"cmov",
]
[[package]]
name = "curve25519-dalek"
-version = "4.1.3"
+version = "5.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
+checksum = "b5eed333089e2e1c1ac8c6c0398e5e2497b4c9926ca6d0365ed1e099afa5bc23"
dependencies = [
"cfg-if",
- "cpufeatures 0.2.17",
+ "cpufeatures 0.3.1",
"curve25519-dalek-derive",
- "digest 0.10.7",
+ "digest 0.11.3",
"fiat-crypto",
+ "rand_core 0.10.1",
"rustc_version",
"subtle",
"zeroize",
@@ -951,7 +1012,7 @@ dependencies = [
"proc-macro2",
"quote",
"strsim 0.11.1",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -984,7 +1045,7 @@ checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785"
dependencies = [
"darling_core 0.24.1",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -995,9 +1056,9 @@ checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06"
[[package]]
name = "defmt"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1"
+checksum = "e4a0795ac740cb54d7b3775c9c17345f6aaecf884c01733b39d6017738e0e3e1"
dependencies = [
"bitflags 1.3.2",
"defmt-macros",
@@ -1005,21 +1066,21 @@ dependencies = [
[[package]]
name = "defmt-macros"
-version = "1.1.1"
+version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8"
+checksum = "840bb056bd99c495c9188198a258ab787c79f306d864db3e6e8451442d6d8366"
dependencies = [
"defmt-parser",
"proc-macro2",
"quote",
- "syn 2.0.119",
+ "syn 3.0.9",
]
[[package]]
name = "defmt-parser"
-version = "1.0.0"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e"
+checksum = "71958f69fb2de856c5dd51ac9377e986ca1aa5919f931bf5395a807277f889a0"
dependencies = [
"thiserror 2.0.21",
]
@@ -1035,6 +1096,16 @@ dependencies = [
"zeroize",
]
+[[package]]
+name = "der"
+version = "0.8.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6a996a108feb8b427a6e12fcf6af4aa83409b79d72c15337d7741368131ee004"
+dependencies = [
+ "const-oid 0.10.2",
+ "zeroize",
+]
+
[[package]]
name = "der-parser"
version = "10.0.0"
@@ -1051,18 +1122,18 @@ dependencies = [
[[package]]
name = "deranged"
-version = "0.5.8"
+version = "0.5.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c"
+checksum = "9e9de72ce2ad1f90dc62fa25f0f430ef85eb4b0d8fa0be4f30373bc40a21d28e"
dependencies = [
"serde_core",
]
[[package]]
name = "derive-deftly"
-version = "1.11.5"
+version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e"
+checksum = "724949bdda5310709ca2f97bf69c2b01e3362f44fef1fb47913445db4c1debb5"
dependencies = [
"derive-deftly-macros",
"heck",
@@ -1070,19 +1141,19 @@ dependencies = [
[[package]]
name = "derive-deftly-macros"
-version = "1.11.5"
+version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863"
+checksum = "17933e749f2502cfcd1c27519292fbbea22e8fc64ce680e863f855e8cd8cce23"
dependencies = [
"heck",
"indexmap 2.14.2",
- "itertools",
+ "itertools 0.15.0",
"proc-macro-crate",
"proc-macro2",
"quote",
- "sha3 0.12.0",
+ "sha3",
"strum",
- "syn 3.0.6",
+ "syn 3.0.9",
"unicode-ident",
"void",
]
@@ -1162,6 +1233,7 @@ dependencies = [
"block-buffer 0.12.1",
"const-oid 0.10.2",
"crypto-common 0.2.2",
+ "ctutils",
]
[[package]]
@@ -1202,7 +1274,16 @@ checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
+]
+
+[[package]]
+name = "document-features"
+version = "0.2.12"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d4b8a88685455ed29a21542a33abd9cb6510b6b129abadabdcef0f4c55bc8f61"
+dependencies = [
+ "litrs",
]
[[package]]
@@ -1223,36 +1304,38 @@ version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
- "der",
+ "der 0.7.10",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
]
[[package]]
name = "ed25519"
-version = "2.2.3"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
+checksum = "29fcf32e6c73d1079f83ab4d782de2d81620346a5f38c6237a86a22f8368980a"
dependencies = [
- "pkcs8",
- "signature",
+ "pkcs8 0.11.0",
+ "signature 3.0.0",
]
[[package]]
name = "ed25519-dalek"
-version = "2.2.0"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
+checksum = "6ebaa1a2bf1290ab3bfe5a7b771d050ebffab2711c19a81691c683a5144a25de"
dependencies = [
"curve25519-dalek",
"ed25519",
- "merlin",
- "rand_core 0.6.4",
+ "keccak",
+ "rand_core 0.10.1",
"serde",
- "sha2 0.10.9",
+ "sha2 0.11.1",
+ "signature 3.0.0",
+ "strobe-rs",
"subtle",
"zeroize",
]
@@ -1271,9 +1354,9 @@ dependencies = [
[[package]]
name = "either"
-version = "1.18.0"
+version = "1.19.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34"
+checksum = "0e9c71c2167ca323c882b99918929403426e2373ea17242ff5653e0d5e1058be"
[[package]]
name = "elliptic-curve"
@@ -1287,7 +1370,7 @@ dependencies = [
"ff",
"generic-array",
"group",
- "pkcs8",
+ "pkcs8 0.10.2",
"rand_core 0.6.4",
"sec1",
"subtle",
@@ -1296,9 +1379,9 @@ dependencies = [
[[package]]
name = "encoding_rs"
-version = "0.8.42"
+version = "0.8.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679"
+checksum = "89253175e704ab869b04f9db224d38630e0211a53841a79181deae0889e169f5"
dependencies = [
"cfg-if",
"core_detect",
@@ -1335,18 +1418,18 @@ dependencies = [
[[package]]
name = "enumset"
-version = "1.1.14"
+version = "1.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0"
+checksum = "2e6bd2d7c76d078cd7a460fa169f778c42c026fe16d90e37d7a45d37181bdce5"
dependencies = [
"enumset_derive",
]
[[package]]
name = "enumset_derive"
-version = "0.15.0"
+version = "0.15.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4bd536557b58c682b217b8fb199afdff47cd3eff260623f19e77074eb073d63a"
+checksum = "f60587c2044cabd91c4d4c73e3912e9f1ea7d76905f31c7eec51869305dae044"
dependencies = [
"darling 0.21.3",
"proc-macro2",
@@ -1356,9 +1439,9 @@ dependencies = [
[[package]]
name = "equivalent"
-version = "1.0.2"
+version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
+checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826"
[[package]]
name = "errno"
@@ -1430,9 +1513,9 @@ dependencies = [
[[package]]
name = "fiat-crypto"
-version = "0.2.9"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
+checksum = "64cd1e32ddd350061ae6edb1b082d7c54915b5c672c389143b9a63403a109f24"
[[package]]
name = "figment"
@@ -1459,9 +1542,9 @@ dependencies = [
[[package]]
name = "find-msvc-tools"
-version = "0.1.14"
+version = "0.1.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484"
+checksum = "1c66915cc5391097de7a2b0b972e8505aa33c0df401fd3a6811dbdb32881ba48"
[[package]]
name = "flate2"
@@ -1492,6 +1575,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2"
+[[package]]
+name = "foldhash"
+version = "0.2.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb"
+
[[package]]
name = "form_urlencoded"
version = "1.2.2"
@@ -1503,9 +1592,9 @@ dependencies = [
[[package]]
name = "fs-mistrust"
-version = "0.15.1"
+version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9"
+checksum = "5732520d7b5c73fdc3302f1fa6f847f7e1b559175455a7c328aecdc1e141aec1"
dependencies = [
"derive_builder_fork_arti",
"dirs",
@@ -1520,9 +1609,9 @@ dependencies = [
[[package]]
name = "fslock-guard"
-version = "0.8.2"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1"
+checksum = "79c81f8b4734d483885639b37b05bf729e56ecf131f9e761807019fa45e6f3e0"
dependencies = [
"libc",
"thiserror 2.0.21",
@@ -1591,7 +1680,7 @@ checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -1738,7 +1827,7 @@ version = "0.15.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1"
dependencies = [
- "foldhash",
+ "foldhash 0.1.5",
]
[[package]]
@@ -1746,20 +1835,28 @@ name = "hashbrown"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100"
+dependencies = [
+ "foldhash 0.2.0",
+]
[[package]]
name = "hashbrown"
version = "0.17.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
+dependencies = [
+ "allocator-api2",
+ "equivalent",
+ "foldhash 0.2.0",
+]
[[package]]
name = "hashlink"
-version = "0.10.0"
+version = "0.12.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1"
+checksum = "a596f1b20ed2cc5ecac41a164aaebc7258057060f06c0cf7a2ba3991ee7990fb"
dependencies = [
- "hashbrown 0.15.5",
+ "hashbrown 0.17.1",
]
[[package]]
@@ -1776,11 +1873,11 @@ checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hkdf"
-version = "0.12.4"
+version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b5f8eb2ad728638ea2c7d47a21db23b7b58a72ed6a38256b8a1849f15fbbdf7"
+checksum = "4aaa26c720c68b866f2c96ef5c1264b3e6f473fe5d4ce61cd44bbe913e553018"
dependencies = [
- "hmac",
+ "hmac 0.13.0",
]
[[package]]
@@ -1792,6 +1889,15 @@ dependencies = [
"digest 0.10.7",
]
+[[package]]
+name = "hmac"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6303bc9732ae41b04cb554b844a762b4115a61bfaa81e3e83050991eeb56863f"
+dependencies = [
+ "digest 0.11.3",
+]
+
[[package]]
name = "hostname-validator"
version = "1.1.1"
@@ -1800,9 +1906,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2"
[[package]]
name = "html-escape"
-version = "0.2.15"
+version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5"
+checksum = "491194d601b4fa9fac3c9571e014070a0ad1bc3392d1bc10419695196a8b3cc3"
[[package]]
name = "http"
@@ -1883,13 +1989,12 @@ dependencies = [
[[package]]
name = "hyper"
-version = "1.11.1"
+version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43"
+checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c"
dependencies = [
"atomic-waker",
"bytes",
- "futures-channel",
"futures-core",
"http",
"http-body",
@@ -1942,9 +2047,9 @@ dependencies = [
[[package]]
name = "icu_collections"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513"
+checksum = "fe090a42a0d3140fed8c63263ed1fecc02422cb2f4bfe55cd8de2de1b8e105dd"
dependencies = [
"displaydoc",
"potential_utf",
@@ -1956,9 +2061,9 @@ dependencies = [
[[package]]
name = "icu_locale_core"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb"
+checksum = "25d29b57c17c6528d0dc4c2141cfa2d08200465acb537a3909c8b9b8a4a984c0"
dependencies = [
"displaydoc",
"litemap",
@@ -1969,9 +2074,9 @@ dependencies = [
[[package]]
name = "icu_normalizer"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f"
+checksum = "404a3b8c486f205c87fe7f23ab6894d84a4ad76d6e4671ae1981ba7def87c599"
dependencies = [
"icu_collections",
"icu_normalizer_data",
@@ -1983,15 +2088,15 @@ dependencies = [
[[package]]
name = "icu_normalizer_data"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0"
+checksum = "20f0088bfb7441f7a37fa0c9e20cb1e9cba1cac6b9ce8cd2a6dc44294f8e8cbc"
[[package]]
name = "icu_properties"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148"
+checksum = "2ea83d991c56158f7cf898bf86436c1dfe7c07ee82fe88951f2695c9f37a379f"
dependencies = [
"displaydoc",
"icu_collections",
@@ -2004,15 +2109,15 @@ dependencies = [
[[package]]
name = "icu_properties_data"
-version = "2.3.0"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa"
+checksum = "b98f29af82d02b163fe50161388350e743bb3107cc8e8c2f7314cb505faddfcf"
[[package]]
name = "icu_provider"
-version = "2.3.1"
+version = "2.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73"
+checksum = "413099315636c588ee8f011190b329b971a09a8fae9d157e01dcae8379468514"
dependencies = [
"displaydoc",
"icu_locale_core",
@@ -2096,6 +2201,15 @@ dependencies = [
"serde_core",
]
+[[package]]
+name = "indoc"
+version = "2.0.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a37b2691796cffeb8a8cd305ac66e65841559f147f4e63231d0eafa4db5384d1"
+dependencies = [
+ "rustversion",
+]
+
[[package]]
name = "infer"
version = "0.22.0"
@@ -2136,18 +2250,31 @@ dependencies = [
[[package]]
name = "inout"
-version = "0.2.2"
+version = "0.2.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7"
+checksum = "3b5cf4372d022092674a83b8febf5865f7a573cc0c5baec5e49c879b6a20978f"
dependencies = [
"hybrid-array",
]
+[[package]]
+name = "instability"
+version = "0.3.14"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4c3b5acc1e2fd9375041a388da33d1eb8aed5f7a8c0dd3543e3ea2805adfbe20"
+dependencies = [
+ "darling 0.24.1",
+ "indoc",
+ "proc-macro2",
+ "quote",
+ "syn 3.0.9",
+]
+
[[package]]
name = "inventory"
-version = "0.3.24"
+version = "0.3.25"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a4f0c30c76f2f4ccee3fe55a2435f691ca00c0e4bd87abe4f4a851b1d4dac39b"
+checksum = "6928282826c822ad91bf1c9a1cb90a30ba1c26770749929b4656cd6be829cd7c"
dependencies = [
"rustversion",
]
@@ -2173,6 +2300,15 @@ version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
+[[package]]
+name = "itertools"
+version = "0.14.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285"
+dependencies = [
+ "either",
+]
+
[[package]]
name = "itertools"
version = "0.15.0"
@@ -2190,9 +2326,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
[[package]]
name = "jiff"
-version = "0.2.37"
+version = "0.2.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb"
+checksum = "3ac703058abac89e5c39b0b879dce2b6b14a4ba36600c7e7e3d0c60a6883ef3c"
dependencies = [
"defmt",
"jiff-core",
@@ -2207,9 +2343,9 @@ dependencies = [
[[package]]
name = "jiff-core"
-version = "0.1.1"
+version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c"
+checksum = "aee43309eb53bafb8c36d35d6e444769c8f8e6206a9fbf7546f5db9d7d4a0e93"
dependencies = [
"defmt",
"log",
@@ -2217,9 +2353,9 @@ dependencies = [
[[package]]
name = "jiff-static"
-version = "0.2.37"
+version = "0.2.39"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212"
+checksum = "6dc32470c67eb7d0e9ccb74303fa925313dc04cecb1aa048beaa6f391e36a93d"
dependencies = [
"jiff-core",
"proc-macro2",
@@ -2229,9 +2365,9 @@ dependencies = [
[[package]]
name = "jiff-tzdb"
-version = "0.1.8"
+version = "0.1.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e"
+checksum = "fa8377070c6bae868759445e5a77f66d84f0b72f3a054bfb00e6d038b8282da7"
[[package]]
name = "jiff-tzdb-platform"
@@ -2244,9 +2380,9 @@ dependencies = [
[[package]]
name = "js-sys"
-version = "0.3.106"
+version = "0.3.107"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5"
+checksum = "8dc09d2154098bbf4ef54eab6b117a3c24cbbffe94046b16ee6d6d4510584d3a"
dependencies = [
"cfg-if",
"futures-util",
@@ -2255,21 +2391,24 @@ dependencies = [
[[package]]
name = "k12"
-version = "0.3.0"
+version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f4dc5fdb62af2f520116927304f15d25b3c2667b4817b90efdc045194c912c54"
+checksum = "38a8cc72399dffa4a445bc5f5a84d4af4d441c76604231bc52e9c529c1d47693"
dependencies = [
- "digest 0.10.7",
- "sha3 0.10.9",
+ "digest 0.11.3",
+ "keccak",
+ "sponge-cursor",
]
[[package]]
-name = "keccak"
-version = "0.1.6"
+name = "kasuari"
+version = "0.4.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cb26cec98cce3a3d96cbb7bced3c4b16e3d13f27ec56dbd62cbc8f39cfb9d653"
+checksum = "72f14c90e40a6133f4805b83542a7ed7364f56070b447f56e7f4e90297122209"
dependencies = [
- "cpufeatures 0.2.17",
+ "hashbrown 0.17.1",
+ "portable-atomic",
+ "thiserror 2.0.21",
]
[[package]]
@@ -2280,6 +2419,7 @@ checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
+ "hybrid-array",
]
[[package]]
@@ -2304,18 +2444,18 @@ dependencies = [
[[package]]
name = "lazy_static"
-version = "1.5.0"
+version = "1.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"
+checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb"
dependencies = [
"spin",
]
[[package]]
name = "libc"
-version = "0.2.189"
+version = "0.2.191"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
+checksum = "65cd87c9a6d60536580b316ad985726104730a5151c99845f2490428ef91240a"
[[package]]
name = "libm"
@@ -2334,15 +2474,24 @@ dependencies = [
[[package]]
name = "libsqlite3-sys"
-version = "0.34.0"
+version = "0.38.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15"
+checksum = "f1d20bef17f513b9b3004532233187769cd072d790971f4e4da0e346eb6401e8"
dependencies = [
"cc",
"pkg-config",
"vcpkg",
]
+[[package]]
+name = "line-clipping"
+version = "0.3.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "e752191d037c44ad111a8caa762921926658402f01cc1253f7bef2020ece4f5e"
+dependencies = [
+ "bitflags 2.13.2",
+]
+
[[package]]
name = "linux-raw-sys"
version = "0.12.1"
@@ -2355,6 +2504,12 @@ version = "0.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae"
+[[package]]
+name = "litrs"
+version = "1.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4744e383959f0db86ede514b809b1c53251889093803c05267acc7d4e7030d70"
+
[[package]]
name = "lock_api"
version = "0.4.14"
@@ -2378,9 +2533,18 @@ dependencies = [
[[package]]
name = "log"
-version = "0.4.34"
+version = "0.4.35"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "5d15f86d12da44cd8d4ec3c73984537eb7f4cadcb9776a575a0a8a05ca11005c"
+
+[[package]]
+name = "lru"
+version = "0.18.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6"
+checksum = "ef9ac18847474e638e3702b76c65d4eb93428471a74778ef0f1be711717f89b5"
+dependencies = [
+ "hashbrown 0.17.1",
+]
[[package]]
name = "matchers"
@@ -2405,25 +2569,13 @@ checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98"
[[package]]
name = "memmap2"
-version = "0.9.11"
+version = "0.9.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0"
+checksum = "6b0ca1155a9ecc25aa12e8313a119f32edf0255fea2f44839c4ac0a12b2ac157"
dependencies = [
"libc",
]
-[[package]]
-name = "merlin"
-version = "3.0.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "58c38e2799fc0978b65dfff8023ec7843e2330bb462f19198840b34b6582397d"
-dependencies = [
- "byteorder",
- "keccak 0.1.6",
- "rand_core 0.6.4",
- "zeroize",
-]
-
[[package]]
name = "mime"
version = "0.3.17"
@@ -2468,9 +2620,9 @@ dependencies = [
[[package]]
name = "mio"
-version = "1.2.3"
+version = "1.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8"
+checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
dependencies = [
"libc",
"log",
@@ -2519,9 +2671,9 @@ checksum = "f6b8866ec53810a9a4b3d434a29801e78c707430a9ae11c2db4b8b62bb9675a0"
[[package]]
name = "notify"
-version = "8.2.0"
+version = "8.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3"
+checksum = "82f812702fbc75d35416d3f884e04925be7e9498f4eceea2e24ba34559284674"
dependencies = [
"bitflags 2.13.2",
"inotify",
@@ -2531,14 +2683,14 @@ dependencies = [
"mio",
"notify-types",
"walkdir",
- "windows-sys 0.60.2",
+ "windows-sys 0.61.2",
]
[[package]]
name = "notify-types"
-version = "2.1.0"
+version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a"
+checksum = "0a437a9308232ce794b1e7fdf540d38ea37efc3c73492640c13b4c8568b8f108"
dependencies = [
"bitflags 2.13.2",
]
@@ -2644,6 +2796,15 @@ dependencies = [
"syn 2.0.119",
]
+[[package]]
+name = "num_threads"
+version = "0.1.8"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cabcc654086861f9e1bc5115258de62ef92c79b20f4615a1b0cc57c4e82027d4"
+dependencies = [
+ "libc",
+]
+
[[package]]
name = "objc2-core-foundation"
version = "0.3.2"
@@ -2677,9 +2838,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "oneshot-fused-workaround"
-version = "0.7.1"
+version = "0.8.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2"
+checksum = "3eed62ee6a84fe244c1e639aca9e0459d4c89f3b1bfbb3bee7c1d90aa503109a"
dependencies = [
"futures",
]
@@ -2746,6 +2907,39 @@ dependencies = [
"sha2 0.10.9",
]
+[[package]]
+name = "palette"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ddeed8580d347d2abf3dcf06a5f0b3dc020258338526b277847cd4248a70fc64"
+dependencies = [
+ "approx",
+ "libm",
+ "palette_derive",
+ "palette_math",
+]
+
+[[package]]
+name = "palette_derive"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "88537020289b719d81be994ccf1bbf4990f477e2f69ee52fe3e45f43a02e56be"
+dependencies = [
+ "by_address",
+ "proc-macro2",
+ "quote",
+ "syn 2.0.119",
+]
+
+[[package]]
+name = "palette_math"
+version = "0.7.7"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "6e6eb142958d64335fb0e345c5b9ead2ecd6fc438c307e9d7d3c4fd428dbaf12"
+dependencies = [
+ "libm",
+]
+
[[package]]
name = "parking"
version = "2.2.1"
@@ -2777,13 +2971,12 @@ dependencies = [
[[package]]
name = "password-hash"
-version = "0.5.0"
+version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166"
+checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
dependencies = [
- "base64ct",
- "rand_core 0.6.4",
- "subtle",
+ "getrandom 0.4.3",
+ "phc",
]
[[package]]
@@ -2807,6 +3000,17 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
+[[package]]
+name = "phc"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
+dependencies = [
+ "base64ct",
+ "ctutils",
+ "getrandom 0.4.3",
+]
+
[[package]]
name = "phf"
version = "0.14.0"
@@ -2882,9 +3086,9 @@ version = "0.7.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c8ffb9f10fa047879315e6625af03c164b16962a5368d724ed16323b68ace47f"
dependencies = [
- "der",
- "pkcs8",
- "spki",
+ "der 0.7.10",
+ "pkcs8 0.10.2",
+ "spki 0.7.3",
]
[[package]]
@@ -2893,8 +3097,18 @@ version = "0.10.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f950b2377845cebe5cf8b5165cb3cc1a5e0fa5cfa3e1f7f55707d8fd82e0a7b7"
dependencies = [
- "der",
- "spki",
+ "der 0.7.10",
+ "spki 0.7.3",
+]
+
+[[package]]
+name = "pkcs8"
+version = "0.11.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "451913da69c775a56034ea8d9003d27ee8948e12443eae7c038ba100a4f21cb7"
+dependencies = [
+ "der 0.8.3",
+ "spki 0.8.1",
]
[[package]]
@@ -2968,9 +3182,9 @@ dependencies = [
[[package]]
name = "powerfmt"
-version = "0.2.0"
+version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391"
+checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958"
[[package]]
name = "ppv-lite86"
@@ -2990,24 +3204,13 @@ dependencies = [
"elliptic-curve",
]
-[[package]]
-name = "priority-queue"
-version = "2.7.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96"
-dependencies = [
- "equivalent",
- "indexmap 2.14.2",
- "serde",
-]
-
[[package]]
name = "proc-macro-crate"
version = "3.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f"
dependencies = [
- "toml_edit 0.25.15+spec-1.1.0",
+ "toml_edit 0.25.18+spec-1.1.0",
]
[[package]]
@@ -3154,12 +3357,78 @@ dependencies = [
"windows-sys 0.59.0",
]
-[[package]]
-name = "rangemap"
-version = "1.8.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
-
+[[package]]
+name = "rangemap"
+version = "1.8.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "a611d15b50743feb4c76b7d03edcb0e64f399c26961e4efe6975bc398be6aa3d"
+
+[[package]]
+name = "ratatui"
+version = "0.30.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3274ba0a2c5e1bcad2a2005d20f4dc59dad26b2eb0940fb094500dba4099d57d"
+dependencies = [
+ "instability",
+ "ratatui-core",
+ "ratatui-crossterm",
+ "ratatui-widgets",
+ "serde",
+]
+
+[[package]]
+name = "ratatui-core"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "cbb175c433c8e28a809d1f5773a2ae96e68c0ce40db865cbab1020bf33ae479c"
+dependencies = [
+ "bitflags 2.13.2",
+ "compact_str",
+ "hashbrown 0.17.1",
+ "itertools 0.14.0",
+ "kasuari",
+ "lru",
+ "palette",
+ "serde",
+ "strum",
+ "thiserror 2.0.21",
+ "unicode-segmentation",
+ "unicode-truncate",
+ "unicode-width",
+]
+
+[[package]]
+name = "ratatui-crossterm"
+version = "0.1.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "567584a3b0e6a8203c23de40b4861497266725eb5363dbfd18a1edd603cca9f0"
+dependencies = [
+ "cfg-if",
+ "crossterm",
+ "instability",
+ "ratatui-core",
+]
+
+[[package]]
+name = "ratatui-widgets"
+version = "0.3.2"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "66e3d19bcc9130ca376277d93b60767ff121ace3be06f5f95f81dd68956407d1"
+dependencies = [
+ "bitflags 2.13.2",
+ "hashbrown 0.17.1",
+ "indoc",
+ "instability",
+ "itertools 0.14.0",
+ "line-clipping",
+ "ratatui-core",
+ "serde",
+ "strum",
+ "time",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
[[package]]
name = "rdrand"
version = "0.9.0"
@@ -3206,7 +3475,7 @@ checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -3249,9 +3518,9 @@ dependencies = [
[[package]]
name = "retry-error"
-version = "0.13.1"
+version = "0.14.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0"
+checksum = "7c0209c6e2f617e050a6dc8d7c91911e95afaa91a03776ecf4dc08d91873bac7"
dependencies = [
"humantime",
"web-time",
@@ -3263,7 +3532,7 @@ version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
- "hmac",
+ "hmac 0.12.1",
"subtle",
]
@@ -3302,20 +3571,30 @@ dependencies = [
"num-integer",
"num-traits",
"pkcs1",
- "pkcs8",
+ "pkcs8 0.10.2",
"rand_core 0.6.4",
"sha2 0.10.9",
- "signature",
- "spki",
+ "signature 2.2.0",
+ "spki 0.7.3",
"subtle",
"zeroize",
]
+[[package]]
+name = "rsqlite-vfs"
+version = "0.1.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "c51c9ae4df8a7fba42103df5c621fa3c37eccf3a3c650879e90fc48b11cc192c"
+dependencies = [
+ "hashbrown 0.16.1",
+ "thiserror 2.0.21",
+]
+
[[package]]
name = "rusqlite"
-version = "0.36.0"
+version = "0.40.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7"
+checksum = "23f2a97da3e3873c73cb2a2e71b35c40ff95e0b1eefa8d72d8499a6928c3b5b3"
dependencies = [
"bitflags 2.13.2",
"fallible-iterator",
@@ -3323,6 +3602,7 @@ dependencies = [
"hashlink",
"libsqlite3-sys",
"smallvec",
+ "sqlite-wasm-rs",
"time",
]
@@ -3346,9 +3626,9 @@ dependencies = [
[[package]]
name = "rustix"
-version = "1.1.5"
+version = "1.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d"
+checksum = "e5e76742e5294153d4dde1207dbec5bdd81eef6fabdc0d2118babadabfd858aa"
dependencies = [
"bitflags 2.13.2",
"errno",
@@ -3359,9 +3639,9 @@ dependencies = [
[[package]]
name = "rustls"
-version = "0.23.45"
+version = "0.23.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634"
+checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
dependencies = [
"log",
"once_cell",
@@ -3383,9 +3663,9 @@ dependencies = [
[[package]]
name = "rustls-webpki"
-version = "0.103.15"
+version = "0.103.16"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2"
+checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
dependencies = [
"ring",
"rustls-pki-types",
@@ -3394,7 +3674,7 @@ dependencies = [
[[package]]
name = "rustpost"
-version = "1.0.0"
+version = "1.1.0"
dependencies = [
"anyhow",
"argon2",
@@ -3404,23 +3684,29 @@ dependencies = [
"captcha",
"chrono",
"clap",
+ "crossterm",
+ "crossterm_winapi",
"flate2",
"futures-util",
"getrandom 0.4.3",
"html-escape",
"http-body-util",
"infer",
+ "ratatui",
+ "ring",
"rusqlite",
"rustls",
"safelog",
+ "semver",
"serde",
"serde_json",
- "sha2 0.11.0",
+ "sha2 0.11.1",
+ "subtle",
"tar",
"tempfile",
"thiserror 2.0.21",
"tokio",
- "toml 1.1.6+spec-1.1.0",
+ "toml 1.2.0+spec-1.1.0",
"tor-cell",
"tor-hsservice",
"tor-proto",
@@ -3428,9 +3714,11 @@ dependencies = [
"tower-http",
"tracing",
"tracing-subscriber",
+ "unicode-segmentation",
"ureq",
"uuid",
"walkdir",
+ "zeroize",
]
[[package]]
@@ -3447,9 +3735,9 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
[[package]]
name = "safelog"
-version = "0.9.1"
+version = "0.10.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029"
+checksum = "75fd05d3d7bc56cd2c75bc51a9037fe60a2d0e9a06c7400c0173eff3816332cf"
dependencies = [
"derive_more",
"educe",
@@ -3478,9 +3766,12 @@ dependencies = [
[[package]]
name = "saturating-time"
-version = "0.4.0"
+version = "0.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144"
+checksum = "f950f7f265dc6fc082387eccf710b1adbf22d75f17c4ee0db4648c9b6959b50d"
+dependencies = [
+ "web-time-compat",
+]
[[package]]
name = "schemars"
@@ -3496,9 +3787,9 @@ dependencies = [
[[package]]
name = "schemars"
-version = "1.2.2"
+version = "1.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a"
+checksum = "ab508826f74a77ca9d5aba6ff19b522583ee3eaf28a19384ff3d0e5835fadf6e"
dependencies = [
"dyn-clone",
"ref-cast",
@@ -3519,9 +3810,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
- "der",
+ "der 0.7.10",
"generic-array",
- "pkcs8",
+ "pkcs8 0.10.2",
"subtle",
"zeroize",
]
@@ -3579,7 +3870,7 @@ checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -3594,9 +3885,9 @@ dependencies = [
[[package]]
name = "serde_json"
-version = "1.0.151"
+version = "1.0.154"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14"
+checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6"
dependencies = [
"itoa",
"memchr",
@@ -3627,9 +3918,9 @@ dependencies = [
[[package]]
name = "serde_spanned"
-version = "1.1.1"
+version = "1.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26"
+checksum = "4e7523beb55eece201a2356bee0bbca0d1ab466c14c07703b2e0ee6d42cb0c2c"
dependencies = [
"serde_core",
]
@@ -3660,7 +3951,7 @@ dependencies = [
"indexmap 2.14.2",
"jiff",
"schemars 0.9.0",
- "schemars 1.2.2",
+ "schemars 1.2.3",
"serde_core",
"serde_json",
"serde_with_macros",
@@ -3676,18 +3967,18 @@ dependencies = [
"darling 0.24.1",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "sha1"
-version = "0.10.7"
+version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8"
+checksum = "aacc4cc499359472b4abe1bf11d0b12e688af9a805fa5e3016f9a386dc2d0214"
dependencies = [
"cfg-if",
- "cpufeatures 0.2.17",
- "digest 0.10.7",
+ "cpufeatures 0.3.1",
+ "digest 0.11.3",
]
[[package]]
@@ -3703,9 +3994,9 @@ dependencies = [
[[package]]
name = "sha2"
-version = "0.11.0"
+version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4"
+checksum = "47d7069beb7d6ac7b9acd1039986e73443f24234f41074da099d6f994ac9ad19"
dependencies = [
"cfg-if",
"cpufeatures 0.3.1",
@@ -3714,22 +4005,23 @@ dependencies = [
[[package]]
name = "sha3"
-version = "0.10.9"
+version = "0.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "77fd7028345d415a4034cf8777cd4f8ab1851274233b45f84e3d955502d93874"
+checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
dependencies = [
- "digest 0.10.7",
- "keccak 0.1.6",
+ "digest 0.11.3",
+ "keccak",
+ "sponge-cursor",
]
[[package]]
-name = "sha3"
-version = "0.12.0"
+name = "shake"
+version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759"
+checksum = "09057cb2149ad4cbd2da1e26b351f9a4c354219421229c69c3063e6f61947c4a"
dependencies = [
"digest 0.11.3",
- "keccak 0.2.2",
+ "keccak",
"sponge-cursor",
]
@@ -3759,6 +4051,27 @@ version = "2.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
+[[package]]
+name = "signal-hook"
+version = "0.3.18"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "d881a16cf4426aa584979d30bd82cb33429027e42122b169753d6ef1085ed6e2"
+dependencies = [
+ "libc",
+ "signal-hook-registry",
+]
+
+[[package]]
+name = "signal-hook-mio"
+version = "0.2.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "b75a19a7a740b25bc7944bdee6172368f988763b744e3d4dfe753f6b4ece40cc"
+dependencies = [
+ "libc",
+ "mio",
+ "signal-hook",
+]
+
[[package]]
name = "signal-hook-registry"
version = "1.4.8"
@@ -3779,6 +4092,15 @@ dependencies = [
"rand_core 0.6.4",
]
+[[package]]
+name = "signature"
+version = "3.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "28d567dcbaf0049cb8ac2608a76cd95ff9e4412e1899d389ee400918ca7537f5"
+dependencies = [
+ "rand_core 0.10.1",
+]
+
[[package]]
name = "simd-adler32"
version = "0.3.10"
@@ -3815,22 +4137,21 @@ dependencies = [
[[package]]
name = "slotmap-careful"
-version = "0.8.1"
+version = "0.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac"
+checksum = "720ae2c21eb5de25d076583f150a9815d0591655c75d106aad3c5207fe1c7e14"
dependencies = [
"paste",
"serde",
"slotmap",
"thiserror 2.0.21",
- "void",
]
[[package]]
name = "smallvec"
-version = "1.16.2"
+version = "1.16.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e"
+checksum = "d0d5d4b1981b8bbbb7aed204cae5726d683d5d6ac7e74ec54122ea28f315e7c8"
[[package]]
name = "socket2"
@@ -3855,7 +4176,17 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d91ed6c858b01f942cd56b37a94b3e0a1798290327d1236e4d9cf4eaca44d29d"
dependencies = [
"base64ct",
- "der",
+ "der 0.7.10",
+]
+
+[[package]]
+name = "spki"
+version = "0.8.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "7ef958a98b9d5da290cfc78946e9f3e61e1e62a18db0d92cac0b83cc161491a9"
+dependencies = [
+ "base64ct",
+ "der 0.8.3",
]
[[package]]
@@ -3864,6 +4195,18 @@ version = "0.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620"
+[[package]]
+name = "sqlite-wasm-rs"
+version = "0.5.5"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75"
+dependencies = [
+ "cc",
+ "js-sys",
+ "rsqlite-vfs",
+ "wasm-bindgen",
+]
+
[[package]]
name = "ssh-cipher-fork-arti"
version = "0.2.0"
@@ -3899,7 +4242,7 @@ dependencies = [
"rsa",
"sec1",
"sha2 0.10.9",
- "signature",
+ "signature 2.2.0",
"ssh-cipher-fork-arti",
"ssh-encoding-fork-arti",
"subtle",
@@ -3918,6 +4261,19 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
+[[package]]
+name = "strobe-rs"
+version = "0.13.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "83561175f0a962dea437885589480d216d8741debf853e0d36edd526344fd273"
+dependencies = [
+ "bitflags 2.13.2",
+ "byteorder",
+ "keccak",
+ "subtle",
+ "zeroize",
+]
+
[[package]]
name = "strsim"
version = "0.10.0"
@@ -3981,9 +4337,9 @@ dependencies = [
[[package]]
name = "syn"
-version = "3.0.6"
+version = "3.0.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee"
+checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
dependencies = [
"proc-macro2",
"quote",
@@ -4015,7 +4371,7 @@ checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -4099,7 +4455,7 @@ checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
@@ -4118,7 +4474,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134"
dependencies = [
"deranged",
+ "js-sys",
+ "libc",
"num-conv",
+ "num_threads",
"powerfmt",
"serde_core",
"time-core",
@@ -4143,9 +4502,9 @@ dependencies = [
[[package]]
name = "tinystr"
-version = "0.8.4"
+version = "0.8.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643"
+checksum = "abc840c238154d58f9e86b2792261edfde565a17696e1cda1a449de27979801a"
dependencies = [
"displaydoc",
"serde_core",
@@ -4160,9 +4519,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee"
[[package]]
name = "tokio"
-version = "1.53.1"
+version = "1.54.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed"
+checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
dependencies = [
"bytes",
"libc",
@@ -4182,14 +4541,14 @@ checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
name = "tokio-util"
-version = "0.7.19"
+version = "0.7.20"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52"
+checksum = "e464cf451ba96ebfc6f9b6542f17ee8b8956e33f1e40d9690624e59d7a7f8a4b"
dependencies = [
"bytes",
"futures-core",
@@ -4213,14 +4572,14 @@ dependencies = [
[[package]]
name = "toml"
-version = "1.1.6+spec-1.1.0"
+version = "1.2.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a"
+checksum = "c5ffcdf8f50436dbbfd675c1fe1b8e0854f42b7defea8584bdebe1aee900e561"
dependencies = [
"indexmap 2.14.2",
"serde_core",
- "serde_spanned 1.1.1",
- "toml_datetime 1.1.1+spec-1.1.0",
+ "serde_spanned 1.1.2",
+ "toml_datetime 1.1.2+spec-1.1.0",
"toml_parser",
"toml_writer",
"winnow 1.0.4",
@@ -4237,9 +4596,9 @@ dependencies = [
[[package]]
name = "toml_datetime"
-version = "1.1.1+spec-1.1.0"
+version = "1.1.2+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7"
+checksum = "2b86d767906c6c42421dcba507eb9d203e779497710a47782a224bb871653053"
dependencies = [
"serde_core",
]
@@ -4260,21 +4619,21 @@ dependencies = [
[[package]]
name = "toml_edit"
-version = "0.25.15+spec-1.1.0"
+version = "0.25.18+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614"
+checksum = "1ec6e78bd70db9420d8d212fa9dc97f116448a1ceff5d00eabb991d407e21396"
dependencies = [
"indexmap 2.14.2",
- "toml_datetime 1.1.1+spec-1.1.0",
+ "toml_datetime 1.1.2+spec-1.1.0",
"toml_parser",
"winnow 1.0.4",
]
[[package]]
name = "toml_parser"
-version = "1.1.3+spec-1.1.0"
+version = "1.1.5+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56"
+checksum = "baa693a8032d7e1cada7d0041e96126df243179ff061456783ac7f12bda4744c"
dependencies = [
"winnow 1.0.4",
]
@@ -4287,15 +4646,15 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801"
[[package]]
name = "toml_writer"
-version = "1.1.2+spec-1.1.0"
+version = "1.2.0+spec-1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2"
+checksum = "009dbb7700f11c55ef83aebbe4c0e9cf1f1ac0844bd8d6f00dc38f302026e13a"
[[package]]
name = "tor-async-utils"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599"
+checksum = "cddda8bed0af7ef6594a8adefbe50ac0b9e82e0d6d323f4f681e42ab700d19ec"
dependencies = [
"cfg-if",
"derive-deftly",
@@ -4319,9 +4678,9 @@ dependencies = [
[[package]]
name = "tor-basic-utils"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da"
+checksum = "0258e51df7fe466d8ae5fd4ac3f4f18d7077317ae7f11726cab4f49285c7e3d6"
dependencies = [
"derive-deftly",
"derive_more",
@@ -4329,7 +4688,7 @@ dependencies = [
"extend",
"getrandom 0.4.3",
"hex",
- "itertools",
+ "itertools 0.15.0",
"libc",
"paste",
"rand 0.10.3",
@@ -4345,13 +4704,13 @@ dependencies = [
[[package]]
name = "tor-bytes"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4"
+checksum = "8329a72a74ca8d7429ba4bd295ae91bb0824132c627803aa55b8e658c7f32e3a"
dependencies = [
"bytes",
"derive-deftly",
- "digest 0.10.7",
+ "digest 0.11.3",
"educe",
"getrandom 0.4.3",
"safelog",
@@ -4363,9 +4722,9 @@ dependencies = [
[[package]]
name = "tor-cell"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750"
+checksum = "ccea3beb4c61b4e80aa1256d4731b3584a5758f047c808d8180fe18318cdb206"
dependencies = [
"amplify",
"bitflags 2.13.2",
@@ -4374,7 +4733,7 @@ dependencies = [
"derive-deftly",
"derive_more",
"educe",
- "itertools",
+ "itertools 0.15.0",
"paste",
"rand 0.10.3",
"smallvec",
@@ -4394,15 +4753,14 @@ dependencies = [
[[package]]
name = "tor-cert"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4"
+checksum = "c100c27c12840bb6b941518bb4732ea8bd37e1d2addbccb3d5aec8052e258fe5"
dependencies = [
"caret",
"derive_builder_fork_arti",
"derive_more",
- "digest 0.10.7",
- "saturating-time",
+ "digest 0.11.3",
"thiserror 2.0.21",
"tor-bytes",
"tor-checkable",
@@ -4413,9 +4771,9 @@ dependencies = [
[[package]]
name = "tor-chanmgr"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab"
+checksum = "6d6086ead3951f0af897d593a33e1c9ed411e8b3d97d82da971ed08838382108"
dependencies = [
"async-trait",
"base64ct",
@@ -4456,13 +4814,13 @@ dependencies = [
[[package]]
name = "tor-checkable"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535"
+checksum = "3388e5189d2ba4d9dfb7e82459d4b9cb5942f3213f8bc8e50ca3bf55a4d9b4ca"
dependencies = [
"humantime",
- "itertools",
- "signature",
+ "itertools 0.15.0",
+ "signature 3.0.0",
"thiserror 2.0.21",
"tor-llcrypto",
"web-time-compat",
@@ -4470,9 +4828,9 @@ dependencies = [
[[package]]
name = "tor-circmgr"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a"
+checksum = "13157eea9e95141a327b947a4cb600525a0db229208c689c7b06b2947ed59ff8"
dependencies = [
"amplify",
"async-trait",
@@ -4485,7 +4843,7 @@ dependencies = [
"educe",
"futures",
"humantime-serde",
- "itertools",
+ "itertools 0.15.0",
"once_cell",
"oneshot-fused-workaround",
"pin-project",
@@ -4520,9 +4878,9 @@ dependencies = [
[[package]]
name = "tor-config"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d"
+checksum = "74d7d9b0b0f96e60e11a98e6d38ead89e0cdd133e76abb9848893c4d8d86c1ce"
dependencies = [
"amplify",
"cfg-if",
@@ -4535,7 +4893,7 @@ dependencies = [
"fs-mistrust",
"futures",
"humantime-serde",
- "itertools",
+ "itertools 0.15.0",
"notify",
"paste",
"postage",
@@ -4545,7 +4903,7 @@ dependencies = [
"serde_ignored",
"strum",
"thiserror 2.0.21",
- "toml 1.1.6+spec-1.1.0",
+ "toml 1.2.0+spec-1.1.0",
"tor-basic-utils",
"tor-error",
"tor-rtcompat",
@@ -4555,9 +4913,9 @@ dependencies = [
[[package]]
name = "tor-config-path"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b"
+checksum = "a4dbc081bd2c113115cc2eaf9e4655a90fdf9884f5d0675b56b3984ed263972a"
dependencies = [
"directories",
"serde",
@@ -4569,12 +4927,12 @@ dependencies = [
[[package]]
name = "tor-consdiff"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848"
+checksum = "a4b852440d099943c5dad7c7937ceb4bbb62770032843815bce35e3b008e12c4"
dependencies = [
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"hex",
"imara-diff",
"static_assertions",
@@ -4586,9 +4944,9 @@ dependencies = [
[[package]]
name = "tor-dirclient"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd"
+checksum = "3a0b528c94c6df7ea4084ceedfc370266f5b004c0827258b4cd081bf5fd81852"
dependencies = [
"async-compression",
"base64ct",
@@ -4598,8 +4956,7 @@ dependencies = [
"http",
"httparse",
"httpdate",
- "itertools",
- "memchr",
+ "itertools 0.15.0",
"thiserror 2.0.21",
"tor-circmgr",
"tor-error",
@@ -4615,9 +4972,9 @@ dependencies = [
[[package]]
name = "tor-dircommon"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71"
+checksum = "70f784279e9a4050c53293abe1ec25200c09a3b53bc74035c59abce0dcc95340"
dependencies = [
"base64ct",
"derive-deftly",
@@ -4636,15 +4993,15 @@ dependencies = [
[[package]]
name = "tor-dirmgr"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e"
+checksum = "86f7ac56b491b26a6f2b73f0f16fa7a0c66b507163e0f0ed850334b86169b326"
dependencies = [
"async-trait",
"base64ct",
"derive_builder_fork_arti",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"educe",
"event-listener",
"fs-mistrust",
@@ -4653,7 +5010,7 @@ dependencies = [
"hex",
"humantime",
"humantime-serde",
- "itertools",
+ "itertools 0.15.0",
"memmap2",
"oneshot-fused-workaround",
"paste",
@@ -4664,7 +5021,7 @@ dependencies = [
"scopeguard",
"serde",
"serde_json",
- "signature",
+ "signature 3.0.0",
"static_assertions",
"strum",
"thiserror 2.0.21",
@@ -4693,9 +5050,9 @@ dependencies = [
[[package]]
name = "tor-error"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad"
+checksum = "f0f15bc3e96c71b38b05e3be3c70926dc747c511ce070294fad9b3d6296040b3"
dependencies = [
"derive_more",
"futures",
@@ -4711,9 +5068,9 @@ dependencies = [
[[package]]
name = "tor-general-addr"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0"
+checksum = "aac41571e0ed5e7337043b2314f414d6963fe161051e64e01bf9ce34541c3680"
dependencies = [
"derive_more",
"thiserror 2.0.21",
@@ -4722,9 +5079,9 @@ dependencies = [
[[package]]
name = "tor-guardmgr"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4"
+checksum = "4a300b7ee657f837bfd7fa71c546fb3a53b291d84a7c0bf2086a53ab5de79b90"
dependencies = [
"amplify",
"base64ct",
@@ -4736,7 +5093,7 @@ dependencies = [
"futures",
"humantime",
"humantime-serde",
- "itertools",
+ "itertools 0.15.0",
"num_enum",
"oneshot-fused-workaround",
"pin-project",
@@ -4766,9 +5123,9 @@ dependencies = [
[[package]]
name = "tor-hsclient"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e"
+checksum = "deb66453800995e28b385cb8dd6fc87a2c4d0ed01ec040c4d6fb242f598f8b6d"
dependencies = [
"async-trait",
"cfg-if",
@@ -4777,7 +5134,7 @@ dependencies = [
"educe",
"either",
"futures",
- "itertools",
+ "itertools 0.15.0",
"oneshot-fused-workaround",
"postage",
"rand 0.10.3",
@@ -4812,23 +5169,23 @@ dependencies = [
[[package]]
name = "tor-hscrypto"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f"
+checksum = "94f91656942a34ffa69b531fa66e82b05115b923ad6bc6356b9defc646f98a6a"
dependencies = [
"cipher 0.5.2",
"data-encoding",
"derive-deftly",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"hex",
"humantime",
- "itertools",
+ "itertools 0.15.0",
"paste",
"rand 0.10.3",
"safelog",
"serde",
- "signature",
+ "signature 3.0.0",
"subtle",
"thiserror 2.0.21",
"tor-basic-utils",
@@ -4845,9 +5202,9 @@ dependencies = [
[[package]]
name = "tor-hsservice"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d61a6b5217c14780f54fa1c8610514b1349c93f657304dec98bd8e3083e1a09f"
+checksum = "74d924b81c856bbc41c014130f28531e313daf3e0d160a2f2bbfe80ec5acba37"
dependencies = [
"amplify",
"async-trait",
@@ -4856,14 +5213,14 @@ dependencies = [
"derive-deftly",
"derive_builder_fork_arti",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"educe",
"fs-mistrust",
"futures",
"growable-bloom-filter",
"hex",
"humantime",
- "itertools",
+ "itertools 0.15.0",
"k12",
"once_cell",
"oneshot-fused-workaround",
@@ -4904,9 +5261,9 @@ dependencies = [
[[package]]
name = "tor-key-forge"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928"
+checksum = "1be620a97a815e7dfb7d720445e9318537f2c17b31c1efbad60812fe61095c5b"
dependencies = [
"derive-deftly",
"derive_more",
@@ -4914,7 +5271,7 @@ dependencies = [
"paste",
"rand 0.10.3",
"rsa",
- "signature",
+ "signature 3.0.0",
"ssh-key-fork-arti",
"thiserror 2.0.21",
"tor-bytes",
@@ -4926,13 +5283,14 @@ dependencies = [
[[package]]
name = "tor-keymgr"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e"
+checksum = "e354c241db42bf5574fa63214ecfcc3f13025068c0e97ab344220052cbf6a31b"
dependencies = [
"amplify",
"arrayvec",
"cfg-if",
+ "data-encoding",
"derive-deftly",
"derive_builder_fork_arti",
"derive_more",
@@ -4942,11 +5300,11 @@ dependencies = [
"glob-match",
"humantime",
"inventory",
- "itertools",
+ "itertools 0.15.0",
"rand 0.10.3",
"safelog",
"serde",
- "signature",
+ "signature 3.0.0",
"ssh-key-fork-arti",
"thiserror 2.0.21",
"tor-basic-utils",
@@ -4967,9 +5325,9 @@ dependencies = [
[[package]]
name = "tor-linkspec"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c"
+checksum = "518cea160b47a538e71a1a620e45345b2b61bd83c590d2fa6d7a2a178612d2a4"
dependencies = [
"base64ct",
"by_address",
@@ -4978,7 +5336,7 @@ dependencies = [
"derive_builder_fork_arti",
"derive_more",
"hex",
- "itertools",
+ "itertools 0.15.0",
"safelog",
"serde",
"serde_with",
@@ -4994,9 +5352,9 @@ dependencies = [
[[package]]
name = "tor-llcrypto"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e"
+checksum = "2d0ec60b9eb029431cc291445b8da36f64c2ed50c6456f6d2c41124089f5852f"
dependencies = [
"aes",
"base64ct",
@@ -5005,7 +5363,7 @@ dependencies = [
"der-parser",
"derive-deftly",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"ed25519-dalek",
"educe",
"getrandom 0.2.17",
@@ -5023,9 +5381,10 @@ dependencies = [
"safelog",
"serde",
"sha1",
- "sha2 0.10.9",
- "sha3 0.10.9",
- "signature",
+ "sha2 0.11.1",
+ "sha3",
+ "shake",
+ "signature 3.0.0",
"subtle",
"thiserror 2.0.21",
"tor-error",
@@ -5037,9 +5396,9 @@ dependencies = [
[[package]]
name = "tor-log-ratelim"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544"
+checksum = "c4d7bf6ae76f6a9052c850a1f7f6c174bca3dfedb116fd461fe712690152b267"
dependencies = [
"futures",
"humantime",
@@ -5053,9 +5412,9 @@ dependencies = [
[[package]]
name = "tor-memquota"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d"
+checksum = "6024de1760017b627e98b6976271cd0bf7bd82ab48587f9c1612fed0e0839161"
dependencies = [
"cfg-if",
"derive-deftly",
@@ -5064,7 +5423,7 @@ dependencies = [
"educe",
"extend",
"futures",
- "itertools",
+ "itertools 0.15.0",
"paste",
"pin-project",
"serde",
@@ -5085,30 +5444,30 @@ dependencies = [
[[package]]
name = "tor-memquota-cost"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c"
+checksum = "c2c47b7fe850ceb58552230ccf1df4bf368b4889fe3c945295f9bdc26f233743"
dependencies = [
"derive-deftly",
- "itertools",
+ "itertools 0.15.0",
"paste",
"void",
]
[[package]]
name = "tor-netdir"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798"
+checksum = "f2be9bdf193c4b257ac8de42586eb6344ac7f88c0eed62973b5227db5db29599"
dependencies = [
"async-trait",
"bitflags 2.13.2",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"futures",
"hex",
"humantime",
- "itertools",
+ "itertools 0.15.0",
"num_enum",
"rand 0.10.3",
"serde",
@@ -5130,9 +5489,9 @@ dependencies = [
[[package]]
name = "tor-netdoc"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e"
+checksum = "7f09aee9e7adda6f22afe4cedecec23040ca77aab44c4ac7e3483bc7c52a1313"
dependencies = [
"amplify",
"base64ct",
@@ -5140,7 +5499,7 @@ dependencies = [
"derive-deftly",
"derive_builder_fork_arti",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"educe",
"enumset",
"extend",
@@ -5149,7 +5508,7 @@ dependencies = [
"humantime",
"ipnet",
"iprange",
- "itertools",
+ "itertools 0.15.0",
"memchr",
"paste",
"phf",
@@ -5158,7 +5517,7 @@ dependencies = [
"saturating-time",
"serde",
"serde_with",
- "signature",
+ "signature 3.0.0",
"smallvec",
"strum",
"subtle",
@@ -5183,9 +5542,9 @@ dependencies = [
[[package]]
name = "tor-persist"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed"
+checksum = "126a31abec4b13a89d05cbebaf95a5e8093e1bcd31bfb5e3541f524602f2dc62"
dependencies = [
"amplify",
"derive-deftly",
@@ -5195,7 +5554,7 @@ dependencies = [
"fs-mistrust",
"fslock-guard",
"futures",
- "itertools",
+ "itertools 0.15.0",
"oneshot-fused-workaround",
"paste",
"sanitize-filename",
@@ -5203,7 +5562,6 @@ dependencies = [
"serde_json",
"thiserror 2.0.21",
"time",
- "tor-async-utils",
"tor-basic-utils",
"tor-error",
"tracing",
@@ -5213,9 +5571,9 @@ dependencies = [
[[package]]
name = "tor-proto"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880"
+checksum = "a9f01894c375bfda9afbe290eaf187c7414f454097f2d7e9458bae8ad155cbb7"
dependencies = [
"aes",
"amplify",
@@ -5231,14 +5589,14 @@ dependencies = [
"derive-deftly",
"derive_builder_fork_arti",
"derive_more",
- "digest 0.10.7",
+ "digest 0.11.3",
"educe",
"enum_dispatch",
"futures",
"futures-util",
"hkdf",
- "hmac",
- "itertools",
+ "hmac 0.13.0",
+ "itertools 0.15.0",
"nonany",
"oneshot-fused-workaround",
"pin-project",
@@ -5270,7 +5628,6 @@ dependencies = [
"tor-memquota",
"tor-protover",
"tor-rtcompat",
- "tor-rtmock",
"tor-units",
"tracing",
"typenum",
@@ -5282,12 +5639,14 @@ dependencies = [
[[package]]
name = "tor-protover"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885"
+checksum = "3c11a770e025058d49eea2d5b2b8372627c5d55ea1c4975b7d9fabc7fb29be9c"
dependencies = [
"caret",
"derive-deftly",
+ "derive_more",
+ "itertools 0.15.0",
"paste",
"serde_with",
"thiserror 2.0.21",
@@ -5297,9 +5656,9 @@ dependencies = [
[[package]]
name = "tor-relay-selection"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9"
+checksum = "07e2cb2278dd985503fa638254e966cdfd7fdd5dab18e536d22a68adcc285d65"
dependencies = [
"rand 0.10.3",
"serde",
@@ -5311,9 +5670,9 @@ dependencies = [
[[package]]
name = "tor-rtcompat"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6"
+checksum = "2bdfe451224c812882e6f7cb372a4e6afa3a013851f1ea5617b3acca97c34843"
dependencies = [
"amplify",
"async-trait",
@@ -5346,43 +5705,12 @@ dependencies = [
"zeroize",
]
-[[package]]
-name = "tor-rtmock"
-version = "0.46.0"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3"
-dependencies = [
- "amplify",
- "assert_matches",
- "async-trait",
- "derive-deftly",
- "derive_more",
- "educe",
- "futures",
- "humantime",
- "itertools",
- "oneshot-fused-workaround",
- "pin-project",
- "priority-queue",
- "slotmap-careful",
- "strum",
- "thiserror 2.0.21",
- "tor-error",
- "tor-general-addr",
- "tor-rtcompat",
- "tracing",
- "tracing-test",
- "void",
- "web-time-compat",
-]
-
[[package]]
name = "tor-socksproto"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a"
+checksum = "206c83bf1a57e3866697cbdcf10d66a3d33a628ce3490d5ee2a3551924378208"
dependencies = [
- "amplify",
"caret",
"derive-deftly",
"educe",
@@ -5395,9 +5723,9 @@ dependencies = [
[[package]]
name = "tor-units"
-version = "0.46.0"
+version = "0.48.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555"
+checksum = "7d87e3acf635dfe6bd7fd473606d7295cf5b642f6989d55a2723efbf29793a5a"
dependencies = [
"derive-deftly",
"derive_more",
@@ -5522,27 +5850,6 @@ dependencies = [
"tracing-log",
]
-[[package]]
-name = "tracing-test"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "19a4c448db514d4f24c5ddb9f73f2ee71bfb24c526cf0c570ba142d1119e0051"
-dependencies = [
- "tracing-core",
- "tracing-subscriber",
- "tracing-test-macro",
-]
-
-[[package]]
-name = "tracing-test-macro"
-version = "0.2.6"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d"
-dependencies = [
- "quote",
- "syn 2.0.119",
-]
-
[[package]]
name = "typed-index-collections"
version = "3.5.0"
@@ -5570,15 +5877,15 @@ dependencies = [
[[package]]
name = "unicase"
-version = "2.9.0"
+version = "2.10.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
+checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f"
[[package]]
name = "unicode-ident"
-version = "1.0.26"
+version = "1.0.27"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954"
+checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563"
[[package]]
name = "unicode-segmentation"
@@ -5586,6 +5893,23 @@ version = "1.13.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8"
+[[package]]
+name = "unicode-truncate"
+version = "2.0.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "16b380a1238663e5f8a691f9039c73e1cdae598a30e9855f541d29b08b53e9a5"
+dependencies = [
+ "itertools 0.14.0",
+ "unicode-segmentation",
+ "unicode-width",
+]
+
+[[package]]
+name = "unicode-width"
+version = "0.2.3"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "ca718471876992059f251e33aafc334346ada08042b7661dd1fe313a9bced557"
+
[[package]]
name = "unicode-xid"
version = "0.2.6"
@@ -5616,9 +5940,9 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae"
[[package]]
name = "ureq"
-version = "3.4.2"
+version = "3.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc"
+checksum = "e7be76686bdf7198002062423ca0d8cf9b75a27c8e1de804513b97e97b555d9d"
dependencies = [
"base64 0.23.1",
"log",
@@ -5632,9 +5956,9 @@ dependencies = [
[[package]]
name = "ureq-proto"
-version = "0.6.4"
+version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be"
+checksum = "622dce33c252115466d83f63e9744a18f12b4406aae51a01c15ad8fcf1185eb3"
dependencies = [
"base64 0.23.1",
"http",
@@ -5662,9 +5986,12 @@ checksum = "b8c0a043c9540bae7c578c88f91dda8bd82e59ae27c21baca69c8b191aaf5a6e"
[[package]]
name = "utf8_iter"
-version = "1.0.4"
+version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be"
+checksum = "af3bd9d3e571c26fcce9d033b87ea5ab061b8d2ffe9b7a5675d810d68f03edf0"
+dependencies = [
+ "utf_types",
+]
[[package]]
name = "utf8parse"
@@ -5672,11 +5999,17 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
+[[package]]
+name = "utf_types"
+version = "1.0.0"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "3a3b425286980a367278453f3b6d28cdcb134b574e5a35824a6ff5eebad50bb6"
+
[[package]]
name = "uuid"
-version = "1.26.1"
+version = "1.29.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce"
+checksum = "4a0e2d11620c50695fe12bbbfde96b3477fe606e7284c22d505c12ce51214514"
dependencies = [
"getrandom 0.4.3",
"js-sys",
@@ -5755,12 +6088,11 @@ dependencies = [
[[package]]
name = "wasm-bindgen"
-version = "0.2.129"
+version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409"
+checksum = "a0181c14ddd6c7b8b0b0132a5f1ea2f32be0d893cd3ea97b6e3b28886b8a3d1c"
dependencies = [
"cfg-if",
- "once_cell",
"rustversion",
"wasm-bindgen-macro",
"wasm-bindgen-shared",
@@ -5768,9 +6100,9 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro"
-version = "0.2.129"
+version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535"
+checksum = "6e931a02a39abdbf66966fc35902b5ed58344750a70885bed4ebb3d8eaf28ed9"
dependencies = [
"quote",
"wasm-bindgen-macro-support",
@@ -5778,22 +6110,22 @@ dependencies = [
[[package]]
name = "wasm-bindgen-macro-support"
-version = "0.2.129"
+version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7"
+checksum = "2ca4b54280e455bd831e33a6b8e734baf432fe63fbc35c42ac14ce67a043a53a"
dependencies = [
"bumpalo",
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"wasm-bindgen-shared",
]
[[package]]
name = "wasm-bindgen-shared"
-version = "0.2.129"
+version = "0.2.130"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6"
+checksum = "3b1f9c3798725016c6ee5f2f57869ec0831e8bbc0ab0310a86e787e97f41389e"
dependencies = [
"unicode-ident",
]
@@ -5820,18 +6152,18 @@ dependencies = [
[[package]]
name = "web-time-compat"
-version = "0.2.1"
+version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7"
+checksum = "f854754465eaf0d17b96236f75c6ddfaebf5907dc31c24ecdc265ffe609756c5"
dependencies = [
"web-time",
]
[[package]]
name = "webpki-roots"
-version = "1.0.9"
+version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a"
+checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
dependencies = [
"rustls-pki-types",
]
@@ -5974,7 +6306,7 @@ version = "0.52.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d"
dependencies = [
- "windows-targets 0.52.6",
+ "windows-targets",
]
[[package]]
@@ -5983,16 +6315,7 @@ version = "0.59.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1e38bc4d79ed67fd075bcc251a1c39b32a1776bbe92e5bef1f0bf1f8c531853b"
dependencies = [
- "windows-targets 0.52.6",
-]
-
-[[package]]
-name = "windows-sys"
-version = "0.60.2"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb"
-dependencies = [
- "windows-targets 0.53.5",
+ "windows-targets",
]
[[package]]
@@ -6010,31 +6333,14 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973"
dependencies = [
- "windows_aarch64_gnullvm 0.52.6",
- "windows_aarch64_msvc 0.52.6",
- "windows_i686_gnu 0.52.6",
- "windows_i686_gnullvm 0.52.6",
- "windows_i686_msvc 0.52.6",
- "windows_x86_64_gnu 0.52.6",
- "windows_x86_64_gnullvm 0.52.6",
- "windows_x86_64_msvc 0.52.6",
-]
-
-[[package]]
-name = "windows-targets"
-version = "0.53.5"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3"
-dependencies = [
- "windows-link",
- "windows_aarch64_gnullvm 0.53.1",
- "windows_aarch64_msvc 0.53.1",
- "windows_i686_gnu 0.53.1",
- "windows_i686_gnullvm 0.53.1",
- "windows_i686_msvc 0.53.1",
- "windows_x86_64_gnu 0.53.1",
- "windows_x86_64_gnullvm 0.53.1",
- "windows_x86_64_msvc 0.53.1",
+ "windows_aarch64_gnullvm",
+ "windows_aarch64_msvc",
+ "windows_i686_gnu",
+ "windows_i686_gnullvm",
+ "windows_i686_msvc",
+ "windows_x86_64_gnu",
+ "windows_x86_64_gnullvm",
+ "windows_x86_64_msvc",
]
[[package]]
@@ -6052,96 +6358,48 @@ version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3"
-[[package]]
-name = "windows_aarch64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53"
-
[[package]]
name = "windows_aarch64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469"
-[[package]]
-name = "windows_aarch64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006"
-
[[package]]
name = "windows_i686_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b"
-[[package]]
-name = "windows_i686_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3"
-
[[package]]
name = "windows_i686_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66"
-[[package]]
-name = "windows_i686_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c"
-
[[package]]
name = "windows_i686_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66"
-[[package]]
-name = "windows_i686_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2"
-
[[package]]
name = "windows_x86_64_gnu"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78"
-[[package]]
-name = "windows_x86_64_gnu"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499"
-
[[package]]
name = "windows_x86_64_gnullvm"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d"
-[[package]]
-name = "windows_x86_64_gnullvm"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1"
-
[[package]]
name = "windows_x86_64_msvc"
version = "0.52.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec"
-[[package]]
-name = "windows_x86_64_msvc"
-version = "0.53.1"
-source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650"
-
[[package]]
name = "winnow"
version = "0.7.15"
@@ -6183,21 +6441,20 @@ dependencies = [
[[package]]
name = "x25519-dalek"
-version = "2.0.1"
+version = "3.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "c7e468321c81fb07fa7f4c636c3972b9100f0346e5b6a9f2bd0603a52f7ed277"
+checksum = "e7e8131a03190127fb2263afc72b322ecadae46b6ff8c6f399ff5d02f5559af6"
dependencies = [
"curve25519-dalek",
- "rand_core 0.6.4",
- "serde",
+ "rand_core 0.10.1",
"zeroize",
]
[[package]]
name = "xattr"
-version = "1.6.1"
+version = "1.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "32e45ad4206f6d2479085147f02bc2ef834ac85886624a23575ae137c8aa8156"
+checksum = "963d6fab92d5504b971dc8bd90ca743c2575e590bc8071ea5bc571598667ae08"
dependencies = [
"libc",
"rustix",
@@ -6211,9 +6468,9 @@ checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7"
[[package]]
name = "yoke"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5"
+checksum = "417ef3fcf6c300b7dbd25c85531a746931c02ad536f3cf4786ed1e88223340b3"
dependencies = [
"stable_deref_trait",
"yoke-derive",
@@ -6222,30 +6479,30 @@ dependencies = [
[[package]]
name = "yoke-derive"
-version = "0.8.3"
+version = "0.8.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78"
+checksum = "ec8ebde2db3681e8c9980cc27822030e68752690ddfa9473e739aeb4dbde6d71"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure 0.14.0",
]
[[package]]
name = "zerocopy"
-version = "0.8.59"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb"
+checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8"
dependencies = [
"zerocopy-derive",
]
[[package]]
name = "zerocopy-derive"
-version = "0.8.59"
+version = "0.8.63"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82"
+checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f"
dependencies = [
"proc-macro2",
"quote",
@@ -6269,15 +6526,15 @@ checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
"synstructure 0.14.0",
]
[[package]]
name = "zeroize"
-version = "1.9.0"
+version = "1.9.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e"
+checksum = "e13084392c5e4bc371903e2935a5eaeed24905a7511356b883835e18a78f6879"
dependencies = [
"zeroize_derive",
]
@@ -6306,9 +6563,9 @@ dependencies = [
[[package]]
name = "zerovec"
-version = "0.11.8"
+version = "0.11.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8"
+checksum = "f58a2799cfe9a1670c7ea8edbd52ef8edb93cb37a7f83bf31ba9e35d9dae6a40"
dependencies = [
"serde",
"yoke",
@@ -6318,13 +6575,13 @@ dependencies = [
[[package]]
name = "zerovec-derive"
-version = "0.11.6"
+version = "0.11.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
-checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da"
+checksum = "c3743ddbf47738d929cd1ae2e199d40d0162979dfe31aef8ae646d2bf00bf97a"
dependencies = [
"proc-macro2",
"quote",
- "syn 3.0.6",
+ "syn 3.0.9",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index d809062..666b419 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -1,59 +1,94 @@
[package]
name = "rustpost"
-version = "1.0.0"
+version = "1.1.0"
edition = "2024"
license = "MIT"
description = "A single-binary self-hosted microblogging app"
repository = "https://github.com/csd113/RustPost"
-rust-version = "1.91"
+rust-version = "1.99.1"
keywords = ["microblog", "self-hosted", "sqlite", "axum"]
categories = ["web-programming::http-server"]
autobins = false
+autotests = false
+autobenches = false
+
+[lib]
+doctest = false
+test = true
+bench = false
[[bin]]
name = "rustpost-cli"
path = "src/bin/rustpost-cli.rs"
+test = false
+bench = false
[dependencies]
anyhow = "1.0.104"
-arti-client = { version = "=0.46.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] }
-argon2 = "0.5.3"
+arti-client = { version = "=0.48.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] }
+argon2 = "0.6.0"
axum = { version = "0.8.9", features = ["multipart"] }
base64 = "0.23.1"
captcha = { version = "1.0", default-features = false }
chrono = { version = "0.4.45", features = ["serde"] }
-clap = { version = "4.6.6", features = ["derive", "env"] }
+clap = { version = "4.6.7", features = ["derive", "env"] }
+crossterm = "0.29.0"
+ratatui = { version = "0.30.2", default-features = false, features = ["crossterm", "unstable-rendered-line-info"] }
futures-util = "0.3.34"
-flate2 = "1.1.9"
-html-escape = "0.2.15"
+flate2 = "1.1.10"
+html-escape = "0.3.0"
+# Enforce a small streaming cap on URL-encoded forms independently of media.
+http-body-util = "0.1.5"
infer = "0.22"
getrandom = "0.4.3"
-safelog = "0.9.1"
+safelog = "0.10.1"
serde = { version = "1.0.229", features = ["derive"] }
-serde_json = "1.0.151"
-sha2 = "0.11.0"
-rusqlite = { version = "0.36.0", features = ["bundled", "functions"] }
-rustls = { version = "0.23.40", default-features = false, features = ["ring"] }
+serde_json = "1.0.154"
+semver = "1.0.28"
+ring = "0.17.14"
+sha2 = "0.11.1"
+# Already used by the Tor stack; constant-time CSRF digest comparison.
+subtle = "2.6.1"
+# Arti 0.48 requires rusqlite >=0.36,<0.41; SQLite can only be linked once.
+rusqlite = { version = "0.40.2", features = ["bundled", "functions", "backup", "hooks"] }
+rustls = { version = "0.23.46", default-features = false, features = ["ring"] }
tar = "0.4.46"
tempfile = "3.27.0"
-thiserror = "2.0.20"
-tokio = { version = "1.53.1", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] }
-toml = "1.1.4"
-tor-cell = "=0.46.0"
-tor-hsservice = "=0.46.0"
-tor-proto = { version = "=0.46.0", features = ["tokio"] }
-tor-rtcompat = { version = "=0.46.0", default-features = false, features = ["tokio", "rustls"] }
-tower-http = { version = "0.7.0", features = ["fs", "trace"] }
+thiserror = "2.0.21"
+tokio = { version = "1.54.1", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] }
+toml = "1.2.0"
+tor-cell = { version = "=0.48.0", default-features = false }
+tor-hsservice = { version = "=0.48.0", default-features = false }
+tor-proto = { version = "=0.48.0", default-features = false, features = ["tokio"] }
+tor-rtcompat = { version = "=0.48.0", default-features = false, features = ["tokio", "rustls"] }
+tower-http = { version = "0.7.1", features = ["fs", "trace"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
-ureq = { version = "3.4.0", default-features = false, features = ["rustls"] }
-uuid = { version = "1.24.1", features = ["v4", "serde"] }
+ureq = { version = "3.5.1", default-features = false, features = ["rustls"] }
+uuid = { version = "1.29.0", features = ["v4", "serde"] }
walkdir = "2.5"
+zeroize = "1.9.1"
+unicode-segmentation = "1.13.3"
-[dev-dependencies]
-http-body-util = "0.1.5"
+[target.'cfg(windows)'.dependencies]
+# Crossterm's Windows raw-mode reset does not preserve initially disabled flags.
+crossterm_winapi = "0.9.1"
+
+[[test]]
+name = "terminal_regressions"
+path = "tests/terminal_regressions.rs"
[profile.release]
lto = true
codegen-units = 1
strip = true
+
+[lints.rust]
+unsafe_code = "forbid"
+
+[lints.clippy]
+unwrap_used = "deny"
+expect_used = "deny"
+panic = "deny"
+todo = "deny"
+unimplemented = "deny"
diff --git a/Dockerfile b/Dockerfile
index b5852ae..1744de7 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.1 --profile minimal --component rustfmt --component clippy \
+ && rustup default 1.99.1
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,9 +22,10 @@ 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.1.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}"
USER rustpost
VOLUME /data
diff --git a/README.md b/README.md
index d78dee6..b3e5041 100644
--- a/README.md
+++ b/README.md
@@ -2,231 +2,129 @@
**Your own small social network, on your own computer or server.**
-[](https://github.com/csd113/RustPost/actions/workflows/ci.yml)
+[](https://github.com/csd113/RustPost/actions/workflows/ci.yml)
[](https://github.com/csd113/RustPost/releases/latest)
[](LICENSE)
-RustPost is a place to share short posts, photos, and videos, follow people, and join conversations. You run the site and control its accounts, settings, and data. People use it through a web browser, including on phones.
+RustPost is a website for short posts, photos, videos, and conversations. **Self-hosted** means you run the app and look after your community's accounts, settings, and data. People visit your site in a web browser, including on phones.
-It runs as one application with a local database. No separate database server or cloud account is required. Each RustPost site is its own community; accounts and posts are not shared with other social networks.
+The app includes its own database. You do not need a separate database server, a cloud account, Docker, or Rust to run a downloaded copy. Each RustPost site is an independent community; it does not share accounts or posts with other social networks.
-[See the app](#see-the-app) · [Get started](#getting-started) · [Docker setup](#run-the-container) · [Settings](#settings-and-administration) · [Documentation](#documentation)
-
-## What you can do
-
-- **Post and talk:** share up to 280 characters, reply in threads, repost, like, and save private bookmarks.
-- **Share media:** attach images and videos, and add a profile picture, banner, and bio.
-- **Find your people:** follow accounts, search posts and users, and receive notifications.
-- **Control your experience:** block or mute accounts, hide words, or require approval for new followers.
-- **Manage your account:** change your username, export or import your data, or request deletion with a configurable grace period.
-- **Run your community:** manage users, publish announcements, pause posting with maintenance mode, and create or restore backups.
-
-Optional Tor support lets the same site be reached through an onion address. See the [operator guide](docs/operator-guide.md#tor--arti) for setup and limitations.
-
-## See the app
-
-### Home feed
-
-Read posts from the community and share an update from the same page.
-
-
-
-### Profiles and conversations
-
-Profiles bring a person's bio, follows, and posts together. Replies stay with their original post so conversations are easy to follow.
-
-| Profile | Conversation |
-| --- | --- |
-|  |  |
-
-### Media and mobile
-
-Images appear within posts, and the layout adapts to smaller screens.
-
-| Image post | Phone layout |
-| --- | --- |
-|  |
|
-
-These screenshots use fictional accounts and locally generated sample media. You can [run the same demo](docs/demo-preview.md) without using real account data.
+[Get started](#getting-started) · [Screenshots](#see-the-app) · [Features](#what-you-can-do) · [Manage your site](#manage-your-site) · [All guides](docs/README.md)
## Getting started
-Choose the installation method that suits you:
-
-| Method | What you need | Best for |
-| --- | --- | --- |
-| [Docker container](#run-the-container) | Docker installed and running | Running the ready-made app with media conversion included |
-| [Download the app](#download-the-app) | A matching release for your computer | Running RustPost without Docker or a Rust installation |
-| [Build from source](#build-from-source) | Rust 1.91 or newer | Developers and people who want to build it themselves |
-
-The current release is **[v1.0.0](https://github.com/csd113/RustPost/releases/tag/v1.0.0)**. It includes downloadable apps and the new Docker image. See the [changelog](CHANGELOG.md) for the full release notes.
-
-### Run the container
-
-The Docker image packages RustPost and `ffmpeg`, the tool used to convert uploaded photos and videos. It supports Linux **amd64** and **arm64**, including Docker environments on compatible Windows and macOS computers.
-
-**1. Start RustPost.** Open a terminal and run:
-
-```sh
-docker volume create rustpost-data
-docker run -d --name rustpost \
- -p 127.0.0.1:8080:8080 \
- -v rustpost-data:/data \
- --restart unless-stopped \
- ghcr.io/csd113/rustpost:1.0.0
-```
-
-Docker downloads the image if needed. The `rustpost-data` volume holds your settings, accounts, posts, uploads, and backups, so they survive replacing the container. Keep this volume when upgrading.
-
-**2. Create your administrator account.** This account manages the site:
-
-```sh
-docker exec -it rustpost rustpost-cli --data-dir /data create-admin-interactive
-```
-
-Follow the username and password prompts. Your password is hidden as you type.
-
-**3. Open [http://127.0.0.1:8080](http://127.0.0.1:8080)** in your browser and log in. This setup makes the site available on your computer only. To let others visit, follow [Making your site public](#making-your-site-public).
-
-Useful container commands:
-
-```sh
-docker logs rustpost # View startup messages and errors
-docker stop rustpost # Stop the app
-docker start rustpost # Start it again
-docker restart rustpost # Reload after changing settings
-```
+### 1. Download the app
-Use `ghcr.io/csd113/rustpost:1.0.0` to select this release explicitly. The `latest` tag currently points to the same release, but can change when a newer version is published.
+Open the [latest published release](https://github.com/csd113/RustPost/releases/latest), expand **Assets**, and download the archive for your computer. Choose the app archive, not GitHub's **Source code** download.
-For custom storage mounts, configuration, backups, and upgrades, see the [Docker guide](docs/docker.md).
+The **v1.1.0** release workflow prepares these downloads. Use them when v1.1.0 is published; if the latest release is still v1.0.0, follow its asset names and checksums instead. [Release packaging](docs/development.md#release-packaging) lists the older names.
-### Download the app
-
-Open the [v1.0.0 downloads](https://github.com/csd113/RustPost/releases/tag/v1.0.0) and choose the archive for your computer. Download its matching `.sha256` file too; it lets you check that the download is intact.
-
-| Computer | Archive |
+| Your computer | v1.1.0 download |
| --- | --- |
-| Linux, Intel/AMD 64-bit | `rustpost-linux-x86_64.tar.gz` |
-| Linux, ARM64 | `rustpost-linux-aarch64.tar.gz` |
-| macOS, Apple Silicon | `rustpost-macos-aarch64.tar.gz` |
-| Windows, Intel/AMD 64-bit | `rustpost-windows-x86_64.zip` |
-
-Check the archive before extracting it. For example, on Linux:
+| Linux, Intel or AMD 64-bit | `rustpost-cli-v1.1.0-linux-x86_64.zip` |
+| Linux, ARM64 | `rustpost-cli-v1.1.0-linux-arm64.zip` |
+| macOS, Intel | `rustpost-cli-v1.1.0-macos-intel.zip` |
+| macOS, Apple silicon | `rustpost-cli-v1.1.0-macos-apple-silicon.zip` |
+| Windows, Intel or AMD 64-bit | `rustpost-cli-v1.1.0-windows-x86_64.zip` |
-```sh
-sha256sum -c rustpost-linux-x86_64.tar.gz.sha256
-tar -xzf rustpost-linux-x86_64.tar.gz
-```
+Download `SHA256SUMS.txt` from the same v1.1.0 release to [verify your download](docs/getting-started.md#verify-a-download). Extract the ZIP, then open a terminal **inside its version/platform folder**, beside `rustpost-cli` (or `rustpost-cli.exe`). The archive includes the executable, README, LICENSE, and CHANGELOG. Media conversion tools such as `ffmpeg` are optional and installed separately for native downloads.
-On macOS, use `shasum -a 256 -c rustpost-macos-aarch64.tar.gz.sha256`, then extract the archive. On Windows, compare `Get-FileHash .\rustpost-windows-x86_64.zip -Algorithm SHA256` with the checksum file, then extract the ZIP.
+### 2. Run it
-From the folder where you extracted the archive, start the app:
+On Linux or macOS:
```sh
-# Linux or macOS
-./rustpost/rustpost-cli --data-dir ./rustpost-data serve
+chmod +x rustpost-cli
+./rustpost-cli --data-dir ./rustpost-data serve
```
+On Windows, open PowerShell in the extracted folder:
+
```powershell
-# Windows PowerShell
-.\rustpost\rustpost-cli.exe --data-dir .\rustpost-data serve
+.\rustpost-cli.exe --data-dir .\rustpost-data serve
```
-Follow the first-run administrator prompts. If no prompt appears, open a second terminal in the same folder and run:
-
-```sh
-./rustpost/rustpost-cli --data-dir ./rustpost-data create-admin-interactive
-```
+Keep the terminal open while using your site. RustPost saves your site in the `rustpost-data` folder. **Keep that folder when updating the app**, and always run from this folder when using these relative-path commands.
-On Windows, use `.\rustpost\rustpost-cli.exe` instead. Then open [http://127.0.0.1:8080](http://127.0.0.1:8080) and log in.
+### 3. Create your administrator and open the site
-Installing `ffmpeg` is optional for downloaded apps. Without it, RustPost can serve allowed uploads in their original format.
+Follow the terminal's first-run prompts to choose an administrator username and password. An administrator manages the site. Use a unique password of at least 10 characters; hidden password prompts do not display the characters you type.
-### Build from source
+Once the server has started, open **[http://127.0.0.1:8080](http://127.0.0.1:8080)** on the same computer and log in. Finish any profile setup shown in the browser, then try writing your first post.
-With [Rust 1.91 or newer](https://rustup.rs/) installed, run these commands from the repository folder:
+If no administrator prompt appears, open another terminal beside the executable and run `./rustpost-cli --data-dir ./rustpost-data create-admin-interactive` (on Windows, replace `./rustpost-cli` with `.\rustpost-cli.exe`). Use the same data folder as the running app.
-```sh
-cargo build --release --locked
-./target/release/rustpost-cli --data-dir ./rustpost-data serve
-```
+This first setup is available on your own computer only. When you are ready to invite people, follow [Make your site public](docs/deployment.md). To stop RustPost, press **Ctrl+C** and wait for it to exit. Run the same command to start it again; your data stays in its folder.
-Follow the first-run administrator prompts, then open [http://127.0.0.1:8080](http://127.0.0.1:8080). On Windows, the executable is `.\target\release\rustpost-cli.exe`.
+Need more help? Read the [step-by-step setup and troubleshooting guide](docs/getting-started.md). Prefer a container? Use the [Docker quick start](docs/docker.md#start-rustpost). Developers can [build the current dev branch](docs/development.md#build-and-run).
-## Using your site
-
-After logging in, use the post box to share an update. Open a post to read or write replies, or visit someone's profile to follow them. The Home Feed shows public posts from your site; replies appear in their conversations.
+## What you can do
-Use **Settings** to edit your profile and account preferences. Administrators also have an **Admin** area for users, site announcements, maintenance mode, media status, and backups.
+- **Post and talk:** share short posts (280 characters by default), reply, quote, repost, like, and save private bookmarks.
+- **Share media:** upload images and videos; add a profile picture, banner, and bio.
+- **Find people:** follow accounts, search posts and users, and read notifications.
+- **Choose what you see:** block or mute accounts, hide words, and require approval for new followers.
+- **Manage your account:** change your username, export or import your data, or request deletion with time to cancel.
+- **Run your community:** manage users, publish announcements, pause posting, and create backups.
-Registration is enabled by default. Anonymous posting is disabled by default.
+See [Using RustPost](docs/account-and-instance-features.md) for everyday actions and their limits. Optional [Tor access](docs/operator-guide.md#tor--arti) lets people reach your site through an onion address.
-## Settings and administration
+## See the app
-RustPost creates a `settings.toml` file on first run. This is a plain text settings file; its comments explain each option. Edit the existing entries and restart RustPost to apply file changes.
+### Home feed
-- **Docker:** the file is `/data/settings.toml` inside the saved volume. See [Editing Docker settings](docs/docker.md#editing-settings).
-- **Downloaded or source-built app:** the examples above save it at `rustpost-data/settings.toml`.
+Read posts from the community and share an update from the same page.
-For example, change `name` under `[site]` to rename your community, or set `registration_enabled = false` under `[accounts]` to close new signups. Detailed settings are in the [operator guide](docs/operator-guide.md#configuration).
+
-Always use the same `--data-dir` when starting the app or running administrator commands. That folder contains the database, uploads, settings, and other site files. Keep it private and out of Git.
+### Profiles and conversations
-### Making your site public
+Profiles bring a person's bio, follows, and posts together. Replies stay with their original post so conversations are easy to follow.
-The setup examples above accept visits from your own computer. For a public site, use a server and a reverse proxy—a service that provides HTTPS and forwards visitors to RustPost.
+| Profile | Conversation |
+| --- | --- |
+|  |  |
-Set `server.public_url` to your HTTPS address and `server.cookie_secure = true`. Keep the app's port private behind the proxy. IP-based rate limits use the direct connection address, so visitors behind a proxy can share a limit. The [operator guide](docs/operator-guide.md#security-model) and generated settings explain the security controls.
+### Media and mobile
-The Docker image sets its internal listener to `0.0.0.0` on first boot so port forwarding works. The example still keeps the published port on your computer's loopback address, `127.0.0.1`.
+Images appear within posts, and the layout adapts to smaller screens.
-### Backups
+| Image post | Phone layout |
+| --- | --- |
+|  |
|
-Use **Admin → Backups** to create and download a backup, configure automatic backups, or restore an archive. Automatic backups are off by default. Keep a copy outside the computer running RustPost.
+These screenshots use fictional accounts and locally generated sample media. You can [run the same demo](docs/demo-preview.md) without using real account data.
-You can also create a backup from the terminal:
+## Manage your site
-```sh
-# Docker
-docker exec rustpost rustpost-cli --data-dir /data backup
+Sign in as an administrator and open **Admin**. Use **Deep server settings** to review settings before saving, **Users** to manage accounts, and **Backups** to save a copy of the site. Most configuration changes need a restart; the settings page explains when each change takes effect.
-# Downloaded app (Linux or macOS)
-./rustpost/rustpost-cli --data-dir ./rustpost-data backup
-```
+New registrations are enabled by default; anonymous posting and automatic backups are disabled. You can close registration, rename the site, and adjust post or upload limits. The [operator guide](docs/operator-guide.md) explains these tasks and where your data lives.
-Backups include the database, settings, and media. Tor private keys are excluded unless you explicitly include them. **Restart RustPost after restoring through the admin page.** See the [backup and restore reference](docs/operator-guide.md#backup-and-restore) for restore commands and safeguards.
+**Admin → Software updates** in v1.1.0 checks GitHub and links to a newer stable release. Installation is manual. Follow the [update guide](docs/software-updates.md) to preserve your data.
## Documentation
-| Guide | What it covers |
+| I want to… | Guide |
| --- | --- |
-| [Docker guide](docs/docker.md) | Persistent storage, editing settings, backups, and replacing the container |
-| [Operator guide](docs/operator-guide.md) | Commands, detailed settings, security controls, Tor, media, and release verification |
-| [Account and site features](docs/account-and-instance-features.md) | Protected accounts, account transfers and deletion, announcements, and maintenance mode |
-| [Local demo](docs/demo-preview.md) | Sample accounts and how to reproduce the screenshots |
-| [Changelog](CHANGELOG.md) | Changes in each release |
-
-### Current limitations
-
-RustPost is designed for a single site and does not connect to federated social networks. Media conversion happens during upload, so large files can take time. Search is basic, some reporting and moderation tools are limited, and broader accessibility review is still pending.
+| Download, run, and troubleshoot my first site | [Getting started](docs/getting-started.md) |
+| Run a container with media tools included | [Docker](docs/docker.md) |
+| Learn posting, account settings, and community controls | [Using RustPost](docs/account-and-instance-features.md) |
+| Change settings, manage users, or back up and restore | [Operator guide](docs/operator-guide.md) |
+| Make the site public with HTTPS | [Deployment](docs/deployment.md) |
+| Install a newer version | [Software updates](docs/software-updates.md) |
+| Understand the live terminal display | [Terminal console](docs/terminal-console.md) |
+| Build, contribute, or prepare a release | [Development](docs/development.md) |
+| Explore sample content | [Local demo](docs/demo-preview.md) |
+| Browse reference material and history | [Documentation index](docs/README.md) · [Changelog](CHANGELOG.md) |
-The v1.0.0 release also has a documented upstream dependency advisory and maintenance warnings from its Tor libraries. Read the [release verification notes](docs/operator-guide.md#release-verification), including the accepted `RUSTSEC-2023-0071` exception, before deploying. Tor support does not guarantee anonymity; verify access from a separate Tor client if you rely on it.
+Reading a README from an extracted app archive? The full guides and screenshots are available in the [online repository](https://github.com/csd113/RustPost/tree/dev).
-## Development
-
-The app is written in Rust, uses SQLite for local storage, and serves HTML directly. No frontend build step is needed.
-
-Run the project's checks from the repository folder:
-
-```sh
-cargo fmt --all --check
-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
-```
+These guides describe **v1.1.0** and identify older-release differences where needed. A development build can report the same package version as a release while containing different commits; record the source commit with your build. Download availability is determined by the published release page.
-GitHub Actions also builds and tests on Linux x86_64, Linux ARM64, macOS Apple Silicon, and Windows x86_64. Use the [local demo guide](docs/demo-preview.md) to review the interface with sample content.
+RustPost is designed for a single community. Search and reporting tools are basic, media conversion happens during upload, and broader accessibility review remains outstanding. For public deployments, read the [security and dependency notes](docs/deployment.md#security-and-dependency-review).
## License
-RustPost is available under the [MIT License](LICENSE).
+RustPost is available under the [MIT License](LICENSE). Existing screenshot provenance is described above and in the [demo guide](docs/demo-preview.md).
diff --git a/assets/client-boot.js b/assets/client-boot.js
new file mode 100644
index 0000000..2b34776
--- /dev/null
+++ b/assets/client-boot.js
@@ -0,0 +1 @@
+document.documentElement.classList.add("js-enabled");
\ No newline at end of file
diff --git a/assets/client.js b/assets/client.js
new file mode 100644
index 0000000..c272ae9
--- /dev/null
+++ b/assets/client.js
@@ -0,0 +1,924 @@
+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;
+ }
+ return target.closest('a,button,input,textarea,select,label,form,[role="button"],[data-youtube-preview]');
+}
+
+document.addEventListener("click", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ const formCard = event.target.closest("[data-card-form]");
+ if (formCard && !cardInteractiveTarget(event.target)) {
+ const form = document.getElementById(formCard.getAttribute("data-card-form"));
+ if (form instanceof HTMLFormElement) {
+ event.preventDefault();
+ if (form.requestSubmit) {
+ form.requestSubmit();
+ } else {
+ form.submit();
+ }
+ return;
+ }
+ }
+ const card = event.target.closest("[data-card-href]");
+ if (!card || cardInteractiveTarget(event.target)) {
+ return;
+ }
+ window.location.assign(card.getAttribute("data-card-href"));
+});
+
+function safeYoutubeEmbedSrc(value) {
+ try {
+ const url = new URL(value);
+ if (url.protocol !== "https:" || url.hostname !== "www.youtube-nocookie.com" || !url.pathname.startsWith("/embed/")) {
+ return null;
+ }
+ url.searchParams.set("autoplay", "1");
+ url.searchParams.set("rel", "0");
+ return url.toString();
+ } catch (_error) {
+ return null;
+ }
+}
+
+function activateYoutubePreview(link) {
+ const card = link.closest("[data-youtube-preview]");
+ const player = card ? card.querySelector("[data-youtube-player]") : null;
+ if (!card || !player || player.querySelector("iframe")) {
+ return;
+ }
+ const src = safeYoutubeEmbedSrc(link.getAttribute("data-youtube-embed-src") || "");
+ if (!src) {
+ return;
+ }
+ const iframe = document.createElement("iframe");
+ iframe.className = "youtube-iframe";
+ iframe.src = src;
+ iframe.title = link.getAttribute("data-youtube-title") || "YouTube video";
+ iframe.loading = "lazy";
+ iframe.referrerPolicy = "no-referrer";
+ iframe.allow = "accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share";
+ iframe.setAttribute("allowfullscreen", "");
+ iframe.setAttribute("sandbox", "allow-scripts allow-same-origin allow-presentation");
+ player.hidden = false;
+ player.append(iframe);
+ card.classList.add("youtube-preview-playing");
+}
+
+document.addEventListener("click", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ const link = event.target.closest("[data-youtube-play]");
+ if (!link) {
+ return;
+ }
+ event.preventDefault();
+ activateYoutubePreview(link);
+});
+
+document.addEventListener("keydown", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ if (event.key === "Enter" && event.target === event.target.closest("[data-card-form]")) {
+ const form = document.getElementById(event.target.getAttribute("data-card-form"));
+ if (form instanceof HTMLFormElement) {
+ event.preventDefault();
+ if (form.requestSubmit) {
+ form.requestSubmit();
+ } else {
+ form.submit();
+ }
+ return;
+ }
+ }
+ if (event.key !== "Enter" || event.target !== event.target.closest("[data-card-href]")) {
+ return;
+ }
+ window.location.assign(event.target.getAttribute("data-card-href"));
+});
+
+document.addEventListener("click", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ const backLink = event.target.closest("[data-history-back]");
+ if (!backLink || window.history.length <= 1) {
+ return;
+ }
+ event.preventDefault();
+ window.history.back();
+});
+
+document.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-password-toggle]");
+ if (!button) {
+ return;
+ }
+ const input = document.getElementById(button.getAttribute("data-password-toggle"));
+ if (!input) {
+ return;
+ }
+ const show = input.type === "password";
+ input.type = show ? "text" : "password";
+ button.textContent = show ? "Hide" : "Show";
+ button.setAttribute("aria-label", show ? "Hide password" : "Show password");
+});
+
+function closeRepostMenus(except, restoreFocus) {
+ document.querySelectorAll("[data-repost-menu]").forEach((menu) => {
+ if (menu === except) {
+ return;
+ }
+ const button = document.querySelector(`[aria-controls="${menu.id}"]`);
+ const shouldRestoreFocus = restoreFocus && menu.contains(document.activeElement);
+ menu.hidden = true;
+ if (button) {
+ button.setAttribute("aria-expanded", "false");
+ if (shouldRestoreFocus) {
+ button.focus();
+ }
+ }
+ });
+}
+
+function openRepostMenu(button) {
+ const menu = document.getElementById(button.getAttribute("aria-controls"));
+ if (!menu) {
+ return;
+ }
+ closeRepostMenus(menu, false);
+ menu.hidden = false;
+ button.setAttribute("aria-expanded", "true");
+ const firstItem = menu.querySelector('[role="menuitem"]');
+ if (firstItem) {
+ firstItem.focus();
+ }
+}
+
+document.addEventListener("pointerdown", (event) => {
+ const button = event.target.closest("[data-repost-menu-button]");
+ if (!button) {
+ return;
+ }
+ const timer = window.setTimeout(() => {
+ button.dataset.longPressOpen = "true";
+ openRepostMenu(button);
+ }, 450);
+ button.dataset.longPressTimer = String(timer);
+});
+
+document.addEventListener("pointerup", (event) => {
+ const button = event.target.closest("[data-repost-menu-button]");
+ if (!button || !button.dataset.longPressTimer) {
+ return;
+ }
+ window.clearTimeout(Number.parseInt(button.dataset.longPressTimer, 10));
+ delete button.dataset.longPressTimer;
+});
+
+document.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-repost-menu-button]");
+ if (button && button.dataset.longPressOpen === "true") {
+ event.preventDefault();
+ event.stopPropagation();
+ delete button.dataset.longPressOpen;
+ return;
+ }
+ if (!event.target.closest("[data-repost-control]")) {
+ closeRepostMenus(null, false);
+ }
+});
+
+document.addEventListener("keydown", (event) => {
+ const button = event.target.closest("[data-repost-menu-button]");
+ if (button && (event.key === "ArrowDown" || event.key === "F10")) {
+ event.preventDefault();
+ openRepostMenu(button);
+ return;
+ }
+ if (event.key === "Escape") {
+ closeRepostMenus(null, true);
+ }
+});
+
+const CHARACTER_COUNTER_CLASSES = [
+ "character-counter-normal",
+ "character-counter-warning",
+ "character-counter-danger"
+];
+
+function characterCounterFor(textarea) {
+ const counter = document.querySelector(`[data-character-counter="${textarea.id}"]`);
+ return counter;
+}
+
+function characterCounterClass(remaining) {
+ if (remaining <= 10) {
+ return "character-counter-danger";
+ }
+ if (remaining <= 50) {
+ return "character-counter-warning";
+ }
+ return "character-counter-normal";
+}
+
+function updateComposerSubmit(textarea, remaining) {
+ const form = textarea.closest("form");
+ const submit = form ? form.querySelector('button[type="submit"]') : null;
+ if (!submit) {
+ return;
+ }
+ submit.disabled = remaining < 0 || form.dataset.submitting === "true";
+}
+
+function updateComposerCount(textarea) {
+ const counter = characterCounterFor(textarea);
+ if (!counter) {
+ return;
+ }
+ const max = Number.parseInt(textarea.getAttribute("data-character-limit") || "0", 10);
+ if (!Number.isFinite(max)) {
+ return;
+ }
+ const length = Array.from(textarea.value).length;
+ const remaining = max - length;
+ counter.textContent = `${remaining} remaining`;
+ counter.classList.remove(...CHARACTER_COUNTER_CLASSES);
+ counter.classList.add(characterCounterClass(remaining));
+ updateComposerSubmit(textarea, remaining);
+}
+
+function initializeComposerCounters(root) {
+ if (root.matches && root.matches("textarea[data-character-limit]")) {
+ updateComposerCount(root);
+ }
+ if (root.querySelectorAll) {
+ root.querySelectorAll("textarea[data-character-limit]").forEach(updateComposerCount);
+ }
+}
+
+const mentionStates = new WeakMap();
+const MENTION_DEBOUNCE_MS = 120;
+
+function mentionStateFor(textarea) {
+ let state = mentionStates.get(textarea);
+ if (state) {
+ return state;
+ }
+ const menu = document.getElementById(textarea.getAttribute("aria-controls"));
+ if (!menu) {
+ return null;
+ }
+ state = {
+ textarea,
+ menu,
+ items: [],
+ activeIndex: -1,
+ atIndex: -1,
+ fragment: "",
+ requestId: 0,
+ timer: 0,
+ controller: null
+ };
+ mentionStates.set(textarea, state);
+ return state;
+}
+
+function mentionContext(textarea) {
+ if (textarea.selectionStart !== textarea.selectionEnd) {
+ return null;
+ }
+ const cursor = textarea.selectionStart;
+ const before = textarea.value.slice(0, cursor);
+ const atIndex = before.lastIndexOf("@");
+ if (atIndex < 0) {
+ return null;
+ }
+ const preceding = atIndex > 0 ? before.charAt(atIndex - 1) : "";
+ if (preceding && /[A-Za-z0-9_@.-]/.test(preceding)) {
+ return null;
+ }
+ const fragment = before.slice(atIndex + 1);
+ if (!/^[A-Za-z0-9_-]*$/.test(fragment)) {
+ return null;
+ }
+ return { atIndex, fragment };
+}
+
+function closeMentionMenu(textarea) {
+ const state = mentionStateFor(textarea);
+ if (!state) {
+ return;
+ }
+ if (state.timer) {
+ window.clearTimeout(state.timer);
+ state.timer = 0;
+ }
+ if (state.controller) {
+ state.controller.abort();
+ state.controller = null;
+ }
+ state.items = [];
+ state.activeIndex = -1;
+ state.atIndex = -1;
+ state.fragment = "";
+ state.menu.hidden = true;
+ state.menu.textContent = "";
+ textarea.setAttribute("aria-expanded", "false");
+ textarea.removeAttribute("aria-activedescendant");
+}
+
+function setActiveMention(state, index) {
+ if (state.items.length === 0) {
+ state.activeIndex = -1;
+ state.textarea.removeAttribute("aria-activedescendant");
+ return;
+ }
+ state.activeIndex = (index + state.items.length) % state.items.length;
+ state.menu.querySelectorAll("[role='option']").forEach((option, optionIndex) => {
+ const selected = optionIndex === state.activeIndex;
+ option.setAttribute("aria-selected", selected ? "true" : "false");
+ if (selected) {
+ state.textarea.setAttribute("aria-activedescendant", option.id);
+ option.scrollIntoView({ block: "nearest" });
+ }
+ });
+}
+
+function selectMention(textarea, index) {
+ const state = mentionStateFor(textarea);
+ if (!state || index < 0 || index >= state.items.length) {
+ return;
+ }
+ const context = mentionContext(textarea);
+ if (!context || context.atIndex !== state.atIndex) {
+ closeMentionMenu(textarea);
+ return;
+ }
+ const username = state.items[index].username;
+ const start = state.atIndex;
+ const end = textarea.selectionStart;
+ const suffix = textarea.value.slice(end);
+ const needsSpace = suffix.length === 0 || /^[A-Za-z0-9_@#-]/.test(suffix);
+ const replacement = `@${username}${needsSpace ? " " : ""}`;
+ textarea.value = `${textarea.value.slice(0, start)}${replacement}${suffix}`;
+ const cursor = start + replacement.length;
+ textarea.setSelectionRange(cursor, cursor);
+ closeMentionMenu(textarea);
+ textarea.dispatchEvent(new Event("input", { bubbles: true }));
+ textarea.focus();
+}
+
+function renderMentionSuggestions(state, suggestions) {
+ state.items = suggestions;
+ state.menu.textContent = "";
+ if (suggestions.length === 0) {
+ closeMentionMenu(state.textarea);
+ return;
+ }
+ suggestions.forEach((suggestion, index) => {
+ const option = document.createElement("button");
+ option.type = "button";
+ option.className = "mention-option";
+ option.id = `${state.menu.id}-option-${index}`;
+ option.setAttribute("role", "option");
+ option.setAttribute("aria-selected", "false");
+ option.dataset.username = suggestion.username;
+ const name = document.createElement("span");
+ name.className = "mention-name";
+ name.textContent = suggestion.display_name || suggestion.username;
+ const handle = document.createElement("span");
+ handle.className = "mention-handle";
+ handle.textContent = `@${suggestion.username}`;
+ option.append(name, handle);
+ option.addEventListener("pointerdown", (event) => {
+ event.preventDefault();
+ selectMention(state.textarea, index);
+ });
+ option.addEventListener("mouseenter", () => setActiveMention(state, index));
+ state.menu.append(option);
+ });
+ state.menu.hidden = false;
+ state.textarea.setAttribute("aria-expanded", "true");
+ setActiveMention(state, 0);
+}
+
+function requestMentionSuggestions(textarea) {
+ const state = mentionStateFor(textarea);
+ const context = mentionContext(textarea);
+ if (!state || !context || !window.fetch) {
+ closeMentionMenu(textarea);
+ return;
+ }
+ state.atIndex = context.atIndex;
+ state.fragment = context.fragment;
+ if (state.timer) {
+ window.clearTimeout(state.timer);
+ }
+ state.timer = window.setTimeout(async () => {
+ state.timer = 0;
+ if (state.controller) {
+ state.controller.abort();
+ }
+ const requestId = state.requestId + 1;
+ state.requestId = requestId;
+ state.controller = window.AbortController ? new AbortController() : null;
+ try {
+ const response = await fetch(`/mentions?q=${encodeURIComponent(context.fragment)}`, {
+ headers: { "Accept": "application/json" },
+ credentials: "same-origin",
+ signal: state.controller ? state.controller.signal : undefined
+ });
+ if (!response.ok || requestId !== state.requestId) {
+ return;
+ }
+ const suggestions = await response.json();
+ const current = mentionContext(textarea);
+ if (!current || current.atIndex !== context.atIndex || current.fragment !== context.fragment) {
+ return;
+ }
+ renderMentionSuggestions(state, Array.isArray(suggestions) ? suggestions : []);
+ } catch (error) {
+ if (!state.controller || error.name !== "AbortError") {
+ closeMentionMenu(textarea);
+ }
+ }
+ }, MENTION_DEBOUNCE_MS);
+}
+
+function initializeMentionAutocomplete(root) {
+ if (root.matches && root.matches("textarea[data-mention-autocomplete]")) {
+ mentionStateFor(root);
+ }
+ if (root.querySelectorAll) {
+ root.querySelectorAll("textarea[data-mention-autocomplete]").forEach(mentionStateFor);
+ }
+}
+
+document.addEventListener("input", (event) => {
+ const textarea = event.target.closest("textarea[data-character-limit]");
+ if (textarea) {
+ updateComposerCount(textarea);
+ }
+ const mentionTextarea = event.target.closest("textarea[data-mention-autocomplete]");
+ if (mentionTextarea) {
+ requestMentionSuggestions(mentionTextarea);
+ }
+});
+
+document.addEventListener("keydown", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ const textarea = event.target.closest("textarea[data-mention-autocomplete]");
+ if (!textarea) {
+ return;
+ }
+ const state = mentionStateFor(textarea);
+ if (!state || state.menu.hidden) {
+ return;
+ }
+ if (event.key === "ArrowDown") {
+ event.preventDefault();
+ setActiveMention(state, state.activeIndex + 1);
+ } else if (event.key === "ArrowUp") {
+ event.preventDefault();
+ setActiveMention(state, state.activeIndex - 1);
+ } else if (event.key === "Enter" || event.key === "Tab") {
+ event.preventDefault();
+ selectMention(textarea, state.activeIndex);
+ } else if (event.key === "Escape") {
+ event.preventDefault();
+ closeMentionMenu(textarea);
+ }
+});
+
+document.addEventListener("click", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ document.querySelectorAll("textarea[data-mention-autocomplete]").forEach((textarea) => {
+ const state = mentionStateFor(textarea);
+ if (state && event.target !== textarea && !state.menu.contains(event.target)) {
+ closeMentionMenu(textarea);
+ }
+ });
+});
+
+initializeComposerCounters(document);
+initializeMentionAutocomplete(document);
+window.addEventListener("pageshow", () => initializeComposerCounters(document));
+window.requestAnimationFrame(() => initializeComposerCounters(document));
+if (window.MutationObserver) {
+ new MutationObserver((mutations) => {
+ mutations.forEach((mutation) => {
+ mutation.addedNodes.forEach((node) => {
+ initializeComposerCounters(node);
+ initializeMentionAutocomplete(node);
+ });
+ });
+ }).observe(document.documentElement, { childList: true, subtree: true });
+}
+
+function mediaSummary(input) {
+ if (!input.files || input.files.length === 0) {
+ return "";
+ }
+ if (input.files.length === 1) {
+ return input.files[0].name || "1 media file selected";
+ }
+ return `${input.files.length} media files selected`;
+}
+
+function updateComposerMedia(input) {
+ const form = input.closest("form");
+ if (!form) {
+ return;
+ }
+ const hasMedia = !!(input.files && input.files.length > 0);
+ const selection = form.querySelector("[data-composer-media-selection]");
+ const summary = form.querySelector("[data-composer-media-summary]");
+ const nsfw = form.querySelector("[data-composer-nsfw]");
+ if (summary) {
+ summary.textContent = mediaSummary(input);
+ }
+ if (selection) {
+ selection.hidden = !hasMedia;
+ }
+ if (nsfw && !hasMedia) {
+ nsfw.checked = false;
+ }
+}
+
+document.querySelectorAll("input[type=file][data-composer-media]").forEach((input) => {
+ updateComposerMedia(input);
+ input.addEventListener("change", () => updateComposerMedia(input));
+});
+
+document.addEventListener("click", (event) => {
+ const button = event.target.closest("[data-composer-clear-media]");
+ if (!button) {
+ return;
+ }
+ const form = button.closest("form");
+ const input = form ? form.querySelector("input[type=file][data-composer-media]") : null;
+ if (!input) {
+ return;
+ }
+ input.value = "";
+ updateComposerMedia(input);
+ input.focus();
+});
+
+function mediaFileName(input) {
+ if (!input.files || input.files.length === 0) {
+ return "";
+ }
+ return input.files[0].name || "media file";
+}
+
+function setProfileMediaStatus(frame, message) {
+ const status = frame.querySelector("[data-profile-media-status]");
+ if (status) {
+ status.textContent = message;
+ }
+}
+
+function updateProfileMediaFile(input) {
+ const frame = input.closest("[data-profile-media-frame]");
+ if (!frame) {
+ return;
+ }
+ const fileName = mediaFileName(input);
+ frame.classList.toggle("settings-media-has-file", fileName !== "");
+ if (fileName !== "") {
+ const remove = frame.querySelector("[data-profile-media-delete]");
+ if (remove) {
+ remove.checked = false;
+ frame.classList.remove("settings-media-removing");
+ }
+ setProfileMediaStatus(frame, `${fileName} selected`);
+ } else {
+ setProfileMediaStatus(frame, "");
+ }
+}
+
+function updateProfileMediaDelete(input) {
+ const frame = input.closest("[data-profile-media-frame]");
+ if (!frame) {
+ return;
+ }
+ frame.classList.toggle("settings-media-removing", input.checked);
+ const file = frame.querySelector("[data-profile-media-file]");
+ if (input.checked && file) {
+ file.value = "";
+ updateProfileMediaFile(file);
+ frame.classList.add("settings-media-removing");
+ }
+ const label = input.getAttribute("aria-label") || "Media";
+ setProfileMediaStatus(frame, input.checked ? `${label} selected` : `${label} cancelled`);
+}
+
+document.querySelectorAll("[data-profile-media-file]").forEach(updateProfileMediaFile);
+
+document.addEventListener("change", (event) => {
+ if (!(event.target instanceof Element)) {
+ return;
+ }
+ const file = event.target.closest("[data-profile-media-file]");
+ if (file) {
+ updateProfileMediaFile(file);
+ return;
+ }
+ const remove = event.target.closest("[data-profile-media-delete]");
+ if (remove) {
+ updateProfileMediaDelete(remove);
+ }
+});
+
+function resetSubmittingForms() {
+ document.querySelectorAll('form[data-submitting="true"]').forEach((form) => {
+ delete form.dataset.submitting;
+ form.querySelectorAll("button:disabled").forEach((button) => {
+ button.disabled = false;
+ });
+ });
+}
+
+window.addEventListener("pageshow", (event) => {
+ const nav = performance.getEntriesByType("navigation")[0];
+ const restored = event.persisted || (nav && nav.type === "back_forward");
+ if (restored) {
+ resetSubmittingForms();
+ }
+});
+
+function setButtonState(button, active, label) {
+ button.classList.toggle("active", active);
+ button.setAttribute("aria-pressed", active ? "true" : "false");
+ button.setAttribute("aria-label", label);
+ button.setAttribute("title", label);
+ const text = button.querySelector("[data-button-label]");
+ if (text) {
+ text.textContent = label;
+ }
+}
+
+function clearFormError(form) {
+ const existing = form.querySelector("[data-form-error]");
+ if (existing) {
+ existing.remove();
+ }
+}
+
+function showFormError(form, message) {
+ clearFormError(form);
+ const notice = document.createElement("p");
+ notice.className = "notice error";
+ notice.setAttribute("role", "alert");
+ notice.setAttribute("data-form-error", "");
+ notice.textContent = message;
+ form.prepend(notice);
+}
+
+function enhancedErrorMessage(status) {
+ if (status === 503) {
+ return "The server is busy. Please wait a moment and try again.";
+ }
+ if (status === 429) {
+ return "Too many requests. Please wait a moment and try again.";
+ }
+ if (status === 400 || status === 422) {
+ return "That submission was rejected. Check the text and any attached media, then try again.";
+ }
+ if (status === 401 || status === 403) {
+ return "That action was refused. Your sign-in may have changed or expired. Save any unsaved text before reloading.";
+ }
+ return "Something went wrong. Please try again.";
+}
+
+document.addEventListener("submit", async (event) => {
+ const form = event.target.closest("form[data-enhance]");
+ if (!form || !window.fetch) {
+ return;
+ }
+ event.preventDefault();
+ if (form.dataset.submitting === "true") {
+ return;
+ }
+ form.dataset.submitting = "true";
+ form.setAttribute("aria-busy", "true");
+ const submitter = event.submitter || form.querySelector("button[type=submit]");
+ if (submitter) {
+ submitter.disabled = true;
+ }
+ try {
+ const formData = new FormData(form);
+ const body = form.enctype === "multipart/form-data" ? formData : new URLSearchParams(formData);
+ const response = await fetch(form.action, {
+ method: form.method || "POST",
+ body,
+ headers: { "Accept": "application/json", "X-RustPost-Enhance": "1" },
+ credentials: "same-origin"
+ });
+ if (!response.ok) {
+ showFormError(form, enhancedErrorMessage(response.status));
+ return;
+ }
+ clearFormError(form);
+ const data = await response.json();
+ if (data.kind === "follow") {
+ const followForm = document.querySelector(`[data-follow-user="${data.user_id}"]`);
+ if (followForm) {
+ followForm.action = data.action;
+ const button = followForm.querySelector("button");
+ if (button) {
+ const label = data.following ? "Following" : (data.requested ? "Requested" : "Follow");
+ const aria = data.following ? "Unfollow this account" : (data.requested ? "Cancel follow request" : "Follow this account");
+ button.classList.toggle("active", data.following);
+ button.textContent = label;
+ button.setAttribute("aria-pressed", data.following ? "true" : "false");
+ button.setAttribute("aria-label", aria);
+ button.setAttribute("title", aria);
+ }
+ }
+ document.querySelectorAll(`[data-profile-followers="${data.user_id}"]`).forEach((node) => {
+ node.textContent = `${data.followers} ${data.followers === 1 ? "follower" : "followers"}`;
+ });
+ document.querySelectorAll(`[data-profile-following="${data.user_id}"]`).forEach((node) => {
+ node.textContent = `${data.following_count} following`;
+ });
+ } else if (data.kind === "post-action") {
+ document.querySelectorAll(`[data-post-id="${data.post_id}"]`).forEach((post) => {
+ const likes = post.querySelector('[data-count="likes"]');
+ const reposts = post.querySelector('[data-count="reposts"]');
+ if (likes) {
+ likes.textContent = `${data.likes} ${data.likes === 1 ? "like" : "likes"}`;
+ }
+ if (reposts) {
+ reposts.textContent = `${data.reposts} ${data.reposts === 1 ? "repost" : "reposts"}`;
+ }
+ const liked = post.querySelector('[data-action-kind="like"]');
+ const bookmarked = post.querySelector('[data-action-kind="bookmark"]');
+ const reposted = post.querySelector('[data-action-kind="repost"]');
+ if (liked) {
+ setButtonState(liked, data.liked, data.liked ? "Unlike" : "Like");
+ }
+ if (bookmarked) {
+ setButtonState(bookmarked, data.bookmarked, data.bookmarked ? "Unbookmark" : "Bookmark");
+ }
+ if (reposted) {
+ setButtonState(reposted, data.reposted, data.reposted ? "Unrepost" : "Repost");
+ }
+ });
+ } else if (data.kind === "post-created") {
+ let timeline = document.querySelector(".timeline");
+ const empty = document.querySelector(".empty-state");
+ if (!timeline && empty) {
+ empty.outerHTML = '';
+ timeline = document.querySelector(".timeline");
+ }
+ if (!timeline) {
+ window.location.assign(data.redirect);
+ return;
+ }
+ timeline.insertAdjacentHTML(data.parent_post_id === null ? "afterbegin" : "beforeend", data.html);
+ if (data.parent_post_id !== null) {
+ document.querySelectorAll(`[data-post-id="${data.parent_post_id}"] [data-count="replies"]`).forEach((node) => {
+ const current = Number.parseInt(node.textContent || "0", 10);
+ const next = Number.isFinite(current) ? current + 1 : 1;
+ node.textContent = `${next} ${next === 1 ? "reply" : "replies"}`;
+ });
+ }
+ const created = document.getElementById(`post-${data.post_id}`);
+ if (created) {
+ created.setAttribute("tabindex", "-1");
+ created.focus({ preventScroll: true });
+ }
+ if (window.history && data.redirect) {
+ window.history.pushState(null, "", data.redirect);
+ }
+ form.reset();
+ form.querySelectorAll("textarea[data-character-limit]").forEach(updateComposerCount);
+ form.querySelectorAll("input[type=file][data-composer-media]").forEach(updateComposerMedia);
+ }
+ } catch (_err) {
+ // Do not silently re-POST: the first request may have been committed even
+ // though the response was lost.
+ showFormError(
+ form,
+ "Network problem. Reload the page to check whether your submission was saved before trying again."
+ );
+ } finally {
+ delete form.dataset.submitting;
+ form.removeAttribute("aria-busy");
+ if (submitter) {
+ submitter.disabled = false;
+ }
+ }
+});
+
+document.addEventListener("submit", (event) => {
+ const form = event.target.closest("form");
+ if (!form || form.dataset.enhance) {
+ return;
+ }
+ if ((form.method || "GET").toUpperCase() !== "POST") {
+ return;
+ }
+ if (form.dataset.submitting === "true") {
+ event.preventDefault();
+ return;
+ }
+ form.dataset.submitting = "true";
+ const submitter = event.submitter || form.querySelector("button[type=submit]");
+ if (submitter) {
+ submitter.disabled = true;
+ }
+});
+
+async function copyTextToClipboard(text) {
+ if (navigator.clipboard && navigator.clipboard.writeText) {
+ try {
+ await navigator.clipboard.writeText(text);
+ return;
+ } catch (_err) {
+ // Fall back to the selection-based path below for browsers that expose
+ // the Clipboard API but deny writes in this context.
+ }
+ }
+ const textarea = document.createElement("textarea");
+ textarea.value = text;
+ textarea.setAttribute("readonly", "");
+ textarea.style.position = "fixed";
+ textarea.style.top = "-1000px";
+ document.body.append(textarea);
+ textarea.select();
+ const copied = document.execCommand("copy");
+ textarea.remove();
+ if (!copied) {
+ throw new Error("copy command failed");
+ }
+}
+
+function setCopyButtonLabel(button, label) {
+ const feedback = button.querySelector("[data-copy-feedback]");
+ if (feedback) {
+ feedback.textContent = label;
+ return;
+ }
+ button.textContent = label;
+}
+
+document.addEventListener("click", async (event) => {
+ const button = event.target.closest("[data-copy-text]");
+ if (!button) {
+ return;
+ }
+ event.preventDefault();
+ event.stopPropagation();
+ const original = button.getAttribute("data-copy-label") || button.textContent || "Copy";
+ if (button.dataset.copyTimer) {
+ window.clearTimeout(Number.parseInt(button.dataset.copyTimer, 10));
+ delete button.dataset.copyTimer;
+ }
+ try {
+ await copyTextToClipboard(button.getAttribute("data-copy-text") || "");
+ setCopyButtonLabel(button, button.getAttribute("data-copied-label") || "Copied");
+ } catch (_err) {
+ setCopyButtonLabel(button, button.getAttribute("data-copy-failed-label") || "Failed");
+ }
+ button.dataset.copyTimer = String(window.setTimeout(() => {
+ setCopyButtonLabel(button, original);
+ delete button.dataset.copyTimer;
+ }, 1600));
+});
\ No newline at end of file
diff --git a/assets/site.css b/assets/site.css
new file mode 100644
index 0000000..9844e7f
--- /dev/null
+++ b/assets/site.css
@@ -0,0 +1,46 @@
+
+:root{font-family:Inter,ui-sans-serif,system-ui,-apple-system,BlinkMacSystemFont,"Segoe UI",sans-serif;color-scheme:light;line-height:1.5;--bg:#f5f6f1;--surface:#fff;--surface-subtle:#fbfcfa;--surface-muted:#f4f5f2;--header-bg:rgba(255,255,255,.96);--text:#202124;--text-strong:#172017;--muted:#667064;--muted-strong:#59625a;--border:#dfe4dc;--border-strong:#b9c2b8;--link:#1f5f8b;--link-strong:#24445f;--brand:#163b2f;--brand-hover:#235544;--brand-text:#fff;--hover:#eef3f0;--focus:#2563eb;--shadow:rgba(20,35,30,.04);--reply-border:#c8d8d0;--avatar-bg:#eef3f0;--warning:#9a5a00;--danger:#8a3d2d;--danger-strong:#6f2f22;--danger-bg:#fff8f5;--danger-border:#e6b8a8;--success-bg:#f4fbf5;--success-border:#add7b4;--media-bg:#f6f7f4;--card-gap:.5rem;--section-gap:.75rem;--shell-side:240px;--shell-primary:680px;--shell-gap:1.25rem;--shell-max:1220px;--header-padding-y:.8rem;--header-brand-size:2rem;--hairline:1px;--rail-sticky-top:calc(var(--header-brand-size) + var(--header-padding-y) + var(--header-padding-y) + var(--shell-gap) + var(--hairline))}
+:root[data-theme="dark"]{color-scheme:dark;--bg:#111827;--surface:#182231;--surface-subtle:#1d2939;--surface-muted:#233044;--header-bg:rgba(17,24,39,.96);--text:#eef4fb;--text-strong:#f8fafc;--muted:#c3cfdd;--muted-strong:#d4deea;--border:#344256;--border-strong:#596b83;--link:#8fc7ff;--link-strong:#badcff;--brand:#4f8fc7;--brand-hover:#6aa8df;--brand-text:#06111f;--hover:#243349;--focus:#fbbf24;--shadow:rgba(0,0,0,.26);--reply-border:#4f6680;--avatar-bg:#243349;--warning:#f6c36b;--danger:#ffb4a2;--danger-strong:#ffd2c7;--danger-bg:#3a2020;--danger-border:#8f4d43;--success-bg:#163321;--success-border:#4c8a61;--media-bg:#0f172a}
+*{box-sizing:border-box}body{margin:0;min-width:320px;color:var(--text);background:var(--bg)}a{color:var(--link);text-decoration:none}a:hover{text-decoration:underline}
+.site-header{position:sticky;top:0;z-index:10;background:var(--header-bg);border-bottom:1px solid var(--border);backdrop-filter:blur(8px)}
+.header-inner{max-width:var(--shell-max);margin:0 auto;padding:var(--header-padding-y) 1rem;display:flex;align-items:center;justify-content:space-between;gap:1rem}
+.header-brand-row{display:flex;align-items:center;gap:.75rem;min-width:0;max-width:100%}.brand{display:flex;align-items:center;gap:.55rem;font-weight:800;color:var(--text-strong);min-width:0}.brand span:last-child{overflow-wrap:anywhere}.brand-mark{display:inline-grid;place-items:center;width:var(--header-brand-size);height:var(--header-brand-size);border-radius:7px;background:var(--brand);color:var(--brand-text);flex:0 0 auto}
+.tor-indicator{position:relative;display:inline-flex;align-items:center;min-width:0;max-width:min(18rem,100%);flex:0 1 auto;color:var(--muted-strong);font-size:.84rem;line-height:1;white-space:nowrap}.tor-disclosure{position:relative;min-width:0;max-width:100%;flex:0 1 auto}.tor-pill{display:inline-flex;align-items:center;gap:.38rem;max-width:100%;min-height:2.15rem;border:1px solid var(--border);border-radius:999px;padding:.25rem .68rem;background:var(--surface-subtle);color:var(--link-strong);font-weight:800;cursor:pointer;list-style:none;overflow:hidden}.js-enabled .tor-pill{padding-right:3.85rem}.tor-pill::-webkit-details-marker{display:none}.tor-pill::marker{content:""}.tor-pill:hover{background:var(--hover);text-decoration:none}.tor-pill-label{flex:0 0 auto;color:var(--muted);font-weight:900}.tor-summary-text{display:block;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-variant-numeric:tabular-nums}.tor-details{position:absolute;z-index:12;left:0;top:calc(100% + .35rem);width:max-content;max-width:min(30rem,calc(100vw - 2rem));padding:.55rem .65rem;border:1px solid var(--border-strong);border-radius:8px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);white-space:normal}.tor-full-link{display:block;max-width:min(28rem,calc(100vw - 3.5rem));overflow-wrap:anywhere;color:var(--text-strong);font-weight:800;line-height:1.3}.tor-full-link:hover{color:var(--link-strong)}.tor-copy-button{display:none;position:absolute;z-index:13;right:.22rem;top:50%;transform:translateY(-50%);align-items:center;justify-content:center;width:3.25rem;min-height:1.65rem;border:1px solid var(--border);border-radius:999px;background:var(--surface);color:var(--link-strong);padding:.12rem .35rem;font-size:.78rem;font-weight:850;line-height:1;box-shadow:none}.js-enabled .tor-copy-button{display:inline-flex}.tor-copy-button:hover{background:var(--hover);color:var(--text-strong)}
+nav{display:flex;gap:.35rem;align-items:center;flex-wrap:wrap;justify-content:flex-end}nav a,nav button,.button-link{display:inline-flex;align-items:center;gap:.35rem;min-height:2.35rem;border-radius:7px;padding:.46rem .68rem;color:var(--link-strong);border:1px solid transparent;background:transparent}
+nav a:hover,nav button:hover,.button-link:hover{background:var(--hover);text-decoration:none}nav form,.actions form{display:inline}
+nav svg{width:1.05rem;height:1.05rem;fill:currentColor;flex:0 0 auto}
+.nav-badge{display:inline-grid;place-items:center;min-width:1.25rem;height:1.25rem;border-radius:999px;padding:0 .35rem;background:var(--brand);color:var(--brand-text);font-size:.78rem;font-weight:800;line-height:1}
+main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin:0 auto;display:grid;grid-template-columns:var(--shell-side) minmax(0,var(--shell-primary)) var(--shell-side);gap:var(--shell-gap);align-items:start;justify-content:center}.primary-column{min-width:0;width:100%}.left-rail,.right-rail{min-width:0;position:sticky;top:var(--rail-sticky-top);display:grid;gap:.75rem;align-items:start}.side-rail-card,.rail-nav{background:var(--surface);border:1px solid var(--border);border-radius:8px;color:var(--muted-strong);box-shadow:0 1px 2px var(--shadow)}.side-rail-card{padding:.85rem}.side-rail-card h2{margin:.1rem 0 .6rem;font-size:1rem;color:var(--text)}.rail-nav{display:grid;grid-template-columns:minmax(0,1fr);gap:.2rem;width:100%;padding:.35rem;justify-content:stretch}.rail-nav a,.rail-nav button{width:100%;min-height:2.35rem;justify-content:flex-start;padding:.5rem .65rem}.rail-nav form{display:block}.mobile-nav{display:none}.dashboard-list{display:grid;grid-template-columns:auto minmax(0,1fr);gap:.45rem .75rem;margin:.25rem 0 .85rem}.dashboard-list dt{font-weight:800;color:var(--text)}.dashboard-list dd{margin:0;overflow-wrap:anywhere}.dashboard-account{color:var(--text)}.dashboard-account:hover{text-decoration:none}.dashboard-actions{display:flex;flex-wrap:wrap;gap:.4rem}.site-footer{max-width:var(--shell-max);margin:0 auto;padding:1rem;color:var(--muted);font-size:.9rem}
+.page-header,.post,.composer,.panel,.empty-state,.notice{background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.9rem;box-shadow:0 1px 2px var(--shadow)}.page-header,.composer,.panel,.empty-state,.notice{margin:0 0 var(--section-gap)}
+.page-header h1,.section-heading h1,.panel h1{margin:0;font-size:1.45rem;line-height:1.2}.panel h1+table,.panel h1+form,.panel h1+p,.panel h1+dl{margin-top:.85rem}.page-header p,.muted,.empty-state p{color:var(--muted);margin:.35rem 0 0}.section-heading{display:flex;justify-content:space-between;gap:1rem;align-items:baseline;margin-bottom:.8rem}
+.character-counter{display:inline-block;flex:0 0 auto;min-width:8.5rem;text-align:right;white-space:nowrap;font-variant-numeric:tabular-nums}.character-counter-normal{color:var(--muted)}.character-counter-warning{color:var(--warning)}.character-counter-danger{color:var(--danger)}
+.notifications-hero{background:var(--surface);border:1px solid var(--border);border-radius:8px;margin:0 0 var(--section-gap);padding:1rem;box-shadow:0 1px 2px var(--shadow);display:flex;align-items:center;justify-content:space-between;gap:1rem}.notifications-hero h1{margin:0;font-size:1.55rem;line-height:1.15}.notifications-hero p:not(.eyebrow){margin:.35rem 0 0;color:var(--muted-strong)}.caught-up-pill{display:inline-flex;align-items:center;min-height:2rem;border:1px solid var(--success-border);border-radius:999px;background:var(--success-bg);color:var(--text-strong);padding:.32rem .75rem;font-weight:800}.caught-up{padding:.75rem .85rem}.caught-up p{margin:0}.notification-group{margin:var(--section-gap) .15rem 0;color:var(--muted);font-size:.82rem;text-transform:uppercase;letter-spacing:.08em}.notification-group:first-child{margin-top:0}.notification-row{position:relative;display:grid;grid-template-columns:auto minmax(0,1fr) auto auto;gap:.75rem;align-items:start;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.8rem;box-shadow:0 1px 2px var(--shadow)}.notification-row.unread{border-color:var(--border-strong);background:var(--surface-subtle)}.js-enabled .notification-row[data-card-href],.js-enabled .notification-row[data-card-form]{cursor:pointer}.js-enabled .notification-row[data-card-href]:hover,.js-enabled .notification-row[data-card-form]:hover{border-color:var(--border-strong);background:var(--hover)}.notification-kind{display:grid;place-items:center;width:2rem;height:2rem;border-radius:7px;background:var(--surface-muted);color:var(--link-strong);font-weight:900;font-size:.8rem}.notification-row.unread .notification-kind{background:var(--brand);color:var(--brand-text)}.notification-body{min-width:0}.notification-line{margin:0;overflow-wrap:anywhere}.notification-meta{margin:.35rem 0 0;color:var(--muted);font-size:.88rem}.notification-counts{color:var(--muted-strong);font-weight:800}.notification-preview{display:block;margin:.5rem 0 0;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle);color:var(--muted-strong);overflow-wrap:anywhere}.notification-preview:hover{background:var(--surface);text-decoration:none}.notification-preview.unavailable{border-style:dashed}.notification-actors{margin:.45rem 0 0}.notification-actors summary{display:inline-flex;align-items:center;min-height:1.7rem;color:var(--link-strong);font-weight:800;cursor:pointer}.notification-actors ul{list-style:none;margin:.25rem 0 0;padding:0;display:flex;flex-wrap:wrap;gap:.35rem}.notification-actors li{border:1px solid var(--border);border-radius:999px;background:var(--surface-subtle);padding:.16rem .5rem;font-size:.88rem}.notification-open-form{display:flex;align-items:flex-start}.notification-open{min-height:2rem;padding:.3rem .55rem;background:var(--surface);border-color:var(--border)}.unread-dot{width:.65rem;height:.65rem;border-radius:999px;background:var(--brand);margin-top:.7rem}
+label{display:block;font-weight:700;margin:.85rem 0 .35rem}input,textarea,button,select{font:inherit}input[type=text],input[type=search],input[type=password],input[type=url],input:not([type]),textarea,select{width:100%;padding:.72rem .8rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text)}textarea{resize:none;min-height:7rem}::placeholder{color:var(--muted)}
+input[type=checkbox]{accent-color:var(--brand)}.check-row,.theme-toggle{display:flex;align-items:center;gap:.55rem;font-weight:700;color:var(--text)}.theme-toggle{padding:.65rem .75rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle)}.theme-toggle input{width:auto}
+input[type=text].password-visible{padding-right:.8rem}.password-control{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.45rem;align-items:center}.password-control input{min-width:0}.password-toggle{display:none;background:var(--surface);color:var(--link-strong);border-color:var(--border);min-width:4.5rem}.js-enabled .password-toggle{display:inline-block}.auth-submit{margin-top:1.15rem}.auth-form{margin-top:.35rem}.auth-form .field-help{margin:.15rem 0 .4rem;color:var(--muted-strong)}
+.search-panel h1{margin-bottom:.75rem}.search-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center}.search-form input{min-width:0}.search-results{display:grid;gap:var(--section-gap)}.section-title{margin:.2rem 0 var(--section-gap);font-size:1.05rem;color:var(--text)}.search-results>.section-title{margin:0}.search-users{margin:0}.search-users .section-title{margin-top:0}.search-account{grid-template-columns:auto minmax(0,1fr)}
+input:focus,textarea:focus,select:focus,button:focus-visible,a:focus-visible{outline:3px solid var(--focus);outline-offset:2px}button,.primary{border:1px solid var(--brand);background:var(--brand);color:var(--brand-text);border-radius:7px;padding:.5rem .8rem;cursor:pointer;font-weight:700}button:hover,.primary:hover{background:var(--brand-hover);text-decoration:none}button:disabled,.primary:disabled,button:disabled:hover,.primary:disabled:hover{border-color:var(--border);background:var(--surface-muted);color:var(--muted);cursor:not-allowed;opacity:1}
+nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.42rem .65rem}.rail-nav button{border-color:transparent;background:transparent;color:var(--link-strong);padding:.5rem .65rem}.rail-nav button:hover,.mobile-nav button:hover{background:var(--hover);color:var(--link-strong)}
+.composer-surface{position:relative;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);overflow:visible}.composer-surface textarea{border:0;border-radius:0;background:transparent;min-height:7rem;resize:none}.composer-surface textarea:focus{outline:0;box-shadow:inset 0 0 0 3px var(--focus)}.mention-menu[hidden]{display:none}.mention-menu{position:absolute;z-index:9;left:.55rem;right:.55rem;top:3.1rem;max-height:12rem;overflow:auto;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 8px 24px var(--shadow);padding:.25rem}.mention-option{display:grid;width:100%;grid-template-columns:minmax(0,1fr) auto;gap:.65rem;align-items:center;border:0;border-radius:6px;background:transparent;color:var(--text);padding:.42rem .5rem;text-align:left}.mention-option:hover,.mention-option[aria-selected="true"]{background:var(--hover);color:var(--text-strong)}.mention-name{font-weight:800;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.mention-handle{color:var(--muted);font-size:.9rem}.composer-footer{display:flex;align-items:center;justify-content:space-between;gap:.55rem;border-top:1px solid var(--border);padding:.42rem .5rem;background:var(--surface-subtle)}.composer-file-control{position:relative;display:inline-flex;align-items:center;gap:.45rem;max-width:100%;margin:0;color:var(--link-strong);font-weight:800}.composer-file-input{max-width:100%;color:var(--muted-strong)}.composer-file-input::file-selector-button{border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}.composer-file-input::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}.composer-file-button{display:none}.js-enabled .composer-file-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .composer-file-button{display:inline-flex;align-items:center;gap:.38rem;min-height:2rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.3rem .55rem;cursor:pointer}.composer-file-button svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.js-enabled .composer-file-control:hover .composer-file-button{background:var(--hover);color:var(--text-strong)}.js-enabled .composer-file-input:focus-visible+.composer-file-button{outline:3px solid var(--focus);outline-offset:2px}.composer-media-selection[hidden]{display:none}.composer-media-selection{display:flex;align-items:center;gap:.7rem;flex-wrap:wrap;border-top:1px solid var(--border);padding:.5rem;background:var(--surface)}.composer-media-summary{font-weight:800;color:var(--muted-strong);overflow-wrap:anywhere}.composer-nsfw{margin:0}.composer-clear-media{background:var(--surface);color:var(--link-strong);border-color:var(--border);padding:.32rem .55rem}.composer-clear-media:hover{background:var(--hover);color:var(--text-strong)}.composer-tools{display:flex;align-items:center;justify-content:space-between;gap:.75rem;margin-top:.85rem}
+.thread-nav{display:flex;margin:0 0 var(--section-gap) .85rem}.thread-back{width:2rem;height:2rem;display:inline-flex;align-items:center;justify-content:center;border-radius:999px;color:var(--link-strong)}.thread-back svg{width:1.2rem;height:1.2rem;fill:currentColor}.thread-back:hover{background:var(--hover);text-decoration:none}
+.timeline,.notifications-list,.account-list{display:grid;gap:var(--card-gap)}.timeline+.composer{margin-top:var(--section-gap)}.pinned-timeline{gap:var(--section-gap);margin-bottom:var(--section-gap)}.pinned-timeline>.section-title{margin:0}.post{margin:0;overflow:hidden;position:relative}.js-enabled .post[data-card-href]{cursor:pointer}.js-enabled .post[data-card-href]:hover{border-color:var(--border-strong)}.reply-post{margin-left:1.1rem;border-left:4px solid var(--reply-border);background:var(--surface-subtle)}.reply-post::before{content:"";position:absolute;left:-1.1rem;top:1.25rem;width:1.1rem;border-top:2px solid var(--reply-border)}.anchor-target{position:absolute;top:-5rem}.post-header{display:flex;justify-content:space-between;gap:.65rem;align-items:flex-start}.author-block{display:flex;gap:.55rem;align-items:center;min-width:0}.post-avatar{width:2rem;height:2rem;object-fit:cover;border-radius:999px;border:1px solid var(--border);background:var(--avatar-bg);flex:0 0 auto;margin:0}.post-avatar.placeholder{display:inline-grid;place-items:center;color:var(--muted-strong);font-weight:800}.author-name{font-weight:800;color:var(--text-strong)}.username,.post-time,.counts{color:var(--muted);font-size:.92rem}.text{white-space:pre-wrap;margin:.55rem 0;line-height:1.5;overflow-wrap:anywhere}.post img,.post video{display:block;max-width:100%;border-radius:8px;border:1px solid var(--border);margin-top:.5rem;background:var(--media-bg)}.post img.post-avatar{display:block;margin:0;border-radius:999px}.youtube-previews{display:grid;gap:.5rem;margin:.35rem 0 .55rem}.youtube-preview-card{display:grid;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);color:var(--text);overflow:hidden}.youtube-preview-card:hover{border-color:var(--border-strong);background:var(--hover)}.youtube-preview-main{display:grid;grid-template-columns:minmax(5.5rem,7.5rem) minmax(0,1fr);gap:.65rem;align-items:center;min-height:4.5rem;color:inherit}.youtube-preview-main:hover{text-decoration:none}.youtube-thumbnail-frame{position:relative;display:block;width:100%;aspect-ratio:16/9;overflow:hidden;background:var(--media-bg)}.post img.youtube-thumbnail{width:100%;height:100%;object-fit:cover;margin:0;border:0;border-radius:0}.youtube-play{position:absolute;left:50%;top:50%;width:2rem;height:2rem;border-radius:999px;background:rgba(0,0,0,.68);box-shadow:0 1px 4px rgba(0,0,0,.35);transform:translate(-50%,-50%)}.youtube-play::before{content:"";position:absolute;left:.78rem;top:.55rem;border-top:.45rem solid transparent;border-bottom:.45rem solid transparent;border-left:.65rem solid #fff}.youtube-preview-body{display:grid;gap:.08rem;min-width:0;padding:.45rem .55rem .45rem 0}.youtube-preview-source{color:var(--muted);font-size:.78rem;font-weight:900;text-transform:uppercase}.youtube-preview-title{color:var(--text-strong);font-weight:850;overflow-wrap:anywhere}.youtube-preview-url{color:var(--muted-strong);font-size:.86rem;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.youtube-preview-actions{display:flex;justify-content:flex-end;border-top:1px solid var(--border);padding:.32rem .55rem}.youtube-open-link{color:var(--muted-strong);font-size:.84rem;font-weight:800}.youtube-player-frame{width:100%;aspect-ratio:16/9;background:#000}.youtube-preview-playing .youtube-preview-main{display:none}.youtube-iframe{display:block;width:100%;height:100%;border:0;background:#000}.nsfw-media{position:relative;margin-top:.5rem}.nsfw-media .post img,.nsfw-media .post video{margin-top:0}.nsfw-media-frame{position:relative;display:block;overflow:hidden;border-radius:8px}.nsfw-media-frame img,.nsfw-media-frame video{margin-top:0;filter:blur(24px);transform:scale(1.02)}.nsfw-toggle:checked+.nsfw-media-frame img,.nsfw-toggle:checked+.nsfw-media-frame video{filter:none;transform:none}.nsfw-badge{position:absolute;left:.55rem;bottom:.55rem;border-radius:999px;padding:.18rem .5rem;background:rgba(0,0,0,.72);color:#fff;font-size:.78rem;font-weight:900;letter-spacing:.03em}.nsfw-show{position:absolute;right:.55rem;bottom:.55rem;margin:0;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);color:var(--text-strong);padding:.32rem .65rem;font-weight:900;box-shadow:0 1px 2px var(--shadow);cursor:pointer}.nsfw-show:hover{background:var(--hover)}.nsfw-toggle:focus-visible~.nsfw-show{outline:3px solid var(--focus);outline-offset:2px}.nsfw-toggle:checked~.nsfw-show,.nsfw-toggle:checked+.nsfw-media-frame .nsfw-badge{display:none}
+.counts{display:flex;gap:.5rem;flex-wrap:wrap;margin-top:.3rem;min-height:1.4rem}.edited-marker{font-weight:800;color:var(--muted-strong)}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:none}.actions{display:inline-flex;gap:.25rem;flex-wrap:wrap;align-items:center;margin-top:.5rem;max-width:100%}.icon-button{width:2.2rem;height:2.2rem;display:inline-flex;align-items:center;justify-content:center;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:0}.icon-button svg{width:1.05rem;height:1.05rem;fill:currentColor}.icon-button:hover,.icon-button.active{background:var(--hover);color:var(--text-strong);text-decoration:none}.icon-button.disabled,.icon-button:disabled{color:var(--muted);background:var(--surface-muted);border-color:var(--border);cursor:not-allowed;opacity:.75}.icon-button.disabled:hover,.icon-button:disabled:hover{background:var(--surface-muted);color:var(--muted)}.admin-nsfw-button{min-height:2.2rem;padding:.3rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border);font-size:.86rem}.admin-nsfw-button:hover{background:var(--hover);color:var(--text-strong)}.repost-control{position:relative;display:inline-flex;align-items:center;gap:.25rem}.repost-menu{position:absolute;z-index:8;left:0;top:calc(100% + .25rem);min-width:8.5rem;padding:.3rem;border:1px solid var(--border-strong);border-radius:7px;background:var(--surface);box-shadow:0 6px 18px var(--shadow)}.repost-menu a{display:inline-flex;align-items:center;gap:.35rem;width:100%;min-height:2rem;border-radius:6px;padding:.32rem .55rem;color:var(--link-strong);font-weight:700}.repost-menu a svg{width:1rem;height:1rem;fill:currentColor;flex:0 0 auto}.repost-menu a:hover,.quote-fallback:hover{background:var(--hover);text-decoration:none}.quote-preview{display:block;margin:.6rem 0 .25rem;border:1px solid var(--border);border-radius:7px;background:var(--surface-subtle);overflow:hidden}.quote-preview p{margin:.65rem;color:var(--muted-strong)}.quote-link{display:grid;gap:.2rem;padding:.6rem;color:var(--text)}.quote-link:hover{background:var(--hover);text-decoration:none}.quote-author{font-weight:800}.quote-text{white-space:pre-wrap;overflow-wrap:anywhere}.quote-time{color:var(--muted);font-size:.86rem}.follow-button{min-width:6.6rem}.follow-button.active{background:var(--hover);color:var(--text-strong);border-color:var(--border-strong)}.profile-actions{margin-top:0}.profile-secondary button{background:var(--surface);color:var(--danger);border-color:var(--danger-border);padding:.32rem .5rem;min-height:1.85rem;font-size:.86rem}.profile-secondary button:hover{background:var(--danger-bg);color:var(--danger-strong)}.profile-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:.75rem}.profile-tabs{display:grid;grid-template-columns:repeat(4,minmax(0,1fr));gap:.35rem;margin:0 0 var(--section-gap);border-bottom:1px solid var(--border)}.profile-tabs a{display:flex;align-items:center;justify-content:center;min-height:2.6rem;border-radius:7px 7px 0 0;color:var(--muted-strong);font-weight:850}.profile-tabs a:hover{background:var(--hover);color:var(--text-strong);text-decoration:none}.profile-tabs a.active{color:var(--text-strong);background:var(--surface);box-shadow:inset 0 -3px 0 var(--brand)}.sr-only{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.repost-banner{color:var(--muted-strong);font-size:.9rem;font-weight:800;margin-bottom:.35rem}.unavailable{color:var(--muted)}.empty-state{text-align:center;padding:2rem 1rem}.empty-state h2{margin:0;font-size:1.2rem}.notice.error,.error-panel{border-color:var(--danger-border);background:var(--danger-bg)}.notice.success{border-color:var(--success-border);background:var(--success-bg)}.eyebrow{text-transform:uppercase;letter-spacing:.08em;font-weight:800;color:var(--muted);font-size:.78rem}.noscript-banner{max-width:1100px;margin:.7rem auto 0;padding:.65rem .85rem;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);color:var(--muted-strong)}
+.profile-banner{width:100%;max-height:220px;object-fit:cover;border-radius:8px;border:1px solid var(--border);background:var(--surface-muted)}.profile-heading{display:flex;gap:1rem;align-items:flex-start;margin-top:.85rem}.profile-main{min-width:0;flex:1}.profile-picture{width:88px;height:88px;object-fit:cover;border-radius:999px;border:3px solid var(--surface);background:var(--avatar-bg);flex:0 0 auto}.profile-meta{color:var(--muted-strong);margin:.45rem 0 0}.settings-profile-editor{padding:0;overflow:hidden}.settings-editor-bar{display:flex;justify-content:space-between;align-items:center;gap:1rem;padding:.85rem;border-bottom:1px solid var(--border)}.settings-editor-bar h1{margin:0}.settings-editor-bar .primary{flex:0 0 auto}.settings-profile-form{padding:.85rem;display:grid;gap:1rem}.settings-section{display:grid;gap:.75rem;min-width:0}.settings-section+.settings-section{border-top:1px solid var(--border);padding-top:1rem}.settings-section-heading{display:grid;gap:.18rem}.settings-section-heading h2,.settings-list-panel h2,.settings-security-panel h2,.danger-panel h2{margin:0;font-size:1.05rem;line-height:1.25;color:var(--text-strong)}.settings-section-help,.settings-switch-help{margin:0;color:var(--muted-strong);font-size:.9rem;line-height:1.35;overflow-wrap:anywhere}.settings-profile-fields{gap:.28rem}.settings-profile-fields label,.settings-password-form label{margin:.55rem 0 .28rem}.settings-profile-media{display:grid;gap:.65rem;min-width:0}.settings-banner-wrap{background:var(--media-bg);border-radius:8px;overflow:hidden}.settings-banner-preview{display:block;width:100%;height:170px;object-fit:cover;object-position:left center;background:linear-gradient(135deg,var(--surface-muted),var(--hover));border:1px solid var(--border);border-radius:8px}.settings-banner-preview.placeholder::before{content:"";display:block;width:100%;height:100%}.settings-picture-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:end;margin-top:-42px;padding:0 .75rem .2rem}.settings-picture-preview{width:104px;height:104px;object-fit:cover;border-radius:999px;border:5px solid var(--surface);background:var(--avatar-bg);box-shadow:0 1px 4px var(--shadow)}.settings-picture-preview.placeholder{display:block}.settings-media-controls{display:grid;gap:.45rem;align-content:end;padding-top:2.85rem;min-width:0}.media-control-row{display:flex;align-items:center;gap:.65rem;flex-wrap:wrap;min-width:0}.media-control-row .file-control{display:flex;align-items:center;gap:.35rem;flex-wrap:wrap;margin:0;max-width:100%;overflow-wrap:anywhere}.media-control-row .file-control input[type=file]{max-width:100%}.media-control-row .check-row{margin:0}.settings-switch-list{display:grid;gap:0;border:1px solid var(--border);border-radius:8px;background:var(--surface-subtle);overflow:hidden}.settings-switch-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.85rem;align-items:center;margin:0;padding:.75rem .8rem;color:var(--text);cursor:pointer}.settings-switch-row+.settings-switch-row{border-top:1px solid var(--border)}.settings-switch-copy{display:grid;gap:.12rem;min-width:0}.settings-switch-label{font-weight:800;color:var(--text-strong);overflow-wrap:anywhere}.settings-switch-toggle{position:relative;display:inline-grid;width:2.75rem;height:1.55rem;justify-self:end;flex:0 0 auto}.settings-switch-input{position:absolute;inset:0;z-index:1;width:100%;height:100%;padding:0;margin:0;opacity:0;cursor:pointer}.settings-switch-control{position:relative;display:block;width:2.75rem;height:1.55rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface-muted);box-shadow:inset 0 0 0 1px var(--shadow);transition:background-color .15s ease,border-color .15s ease}.settings-switch-control::before{content:"";position:absolute;left:.16rem;top:50%;width:1.15rem;height:1.15rem;border-radius:999px;background:var(--surface);box-shadow:0 1px 3px var(--shadow);transform:translateY(-50%);transition:transform .15s ease}.settings-switch-input:checked+.settings-switch-control{border-color:var(--brand);background:var(--brand)}.settings-switch-input:checked+.settings-switch-control::before{transform:translate(1.2rem,-50%)}.settings-switch-input:focus-visible+.settings-switch-control{outline:3px solid var(--focus);outline-offset:2px}.settings-form-actions{display:flex;justify-content:flex-end;gap:.5rem;border-top:1px solid var(--border);padding-top:.85rem}.onboarding-panel{overflow:hidden}.onboarding-form{display:grid;gap:.9rem}.onboarding-media-row{display:grid;grid-template-columns:auto minmax(0,1fr);gap:1rem;align-items:center}.onboarding-suggestions{border:1px solid var(--border);border-radius:8px;padding:.75rem;display:grid;gap:.55rem}.onboarding-suggestions legend{font-weight:800;padding:0 .35rem}.onboarding-suggestion{display:grid;grid-template-columns:auto auto minmax(0,1fr);gap:.6rem;align-items:center;margin:0;border:1px solid var(--border);border-radius:7px;padding:.55rem;background:var(--surface-subtle);cursor:pointer}.onboarding-suggestion input{margin:0}.settings-grid{display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-panel{padding:0;overflow:hidden}.deep-settings-form{padding:.85rem;display:grid;gap:.85rem}.deep-settings-group{border:1px solid var(--border);border-radius:8px;padding:.8rem;display:grid;grid-template-columns:repeat(2,minmax(0,1fr));gap:.7rem}.deep-settings-group legend{font-weight:800;padding:0 .35rem}.deep-settings-field{display:grid;gap:.25rem;align-content:start}.deep-settings-field label{font-weight:800}.deep-settings-field input,.deep-settings-field select{min-width:0}.field-help{font-size:.88rem;margin:.05rem 0 .35rem;color:var(--muted-strong)}.deep-settings-confirm .settings-item-list li{display:block}.compact-panel h2,.danger-panel h2{margin:0;font-size:1.05rem}.inline-settings-form{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.55rem;align-items:center;margin:.65rem 0 .75rem}.inline-settings-form input{min-width:0}.settings-password-form{display:grid;gap:0;margin-top:.65rem}.settings-password-form button[type=submit]{margin-top:0}.settings-list-panel,.settings-security-panel{display:grid;gap:.55rem}.settings-item-list{list-style:none;margin:.25rem 0 0;padding:0;display:grid;gap:.45rem}.settings-item-list li{display:flex;justify-content:space-between;align-items:center;gap:.75rem;border:1px solid var(--border);border-radius:7px;padding:.55rem .65rem;background:var(--surface-subtle)}.settings-item-list li>span{min-width:0;overflow-wrap:anywhere}.settings-item-list form{flex:0 0 auto}.settings-item-list button{padding:.32rem .55rem;background:var(--surface);color:var(--link-strong);border-color:var(--border)}.compact-empty{border:1px dashed var(--border);border-radius:7px;padding:.75rem;background:var(--surface-subtle);color:var(--muted-strong)}.compact-empty p{margin:.25rem 0 0}.danger-panel{border-color:var(--danger-border);background:var(--danger-bg)}.danger,.danger-link{border-color:var(--danger-border);background:var(--danger);color:var(--brand-text)}.danger:hover,.danger-link:hover{background:var(--danger-strong);color:var(--brand-text);text-decoration:none}.delete-account-panel p,.danger-panel p{max-width:62ch}.settings-danger-action{margin:.7rem 0 0}.favicon-preview{width:32px;height:32px;object-fit:contain;border:1px solid var(--border);border-radius:6px;background:var(--surface)}.admin-user-search{display:grid;grid-template-columns:minmax(0,1fr) minmax(0,1fr) auto;gap:.65rem;align-items:end;margin-top:.75rem}.admin-user-search label{margin-top:0}.admin-user-search-actions{display:flex;gap:.4rem;align-items:center;margin-bottom:.05rem}.admin-user-row{display:grid;grid-template-columns:minmax(0,1fr) auto;gap:.75rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;margin-top:.65rem;background:var(--surface-subtle)}.admin-user-heading{overflow-wrap:anywhere}.admin-user-statuses,.admin-user-matches{display:flex;flex-wrap:wrap;gap:.35rem;margin-top:.45rem}.admin-user-pill,.admin-user-match{display:inline-flex;align-items:center;min-height:1.55rem;border:1px solid var(--border);border-radius:999px;padding:.15rem .5rem;background:var(--surface);font-size:.82rem;font-weight:800;color:var(--muted-strong)}.admin-user-match{border-color:var(--success-border);background:var(--success-bg);color:var(--text)}.admin-user-meta{margin:.6rem 0 0}.admin-post-preview{margin:.55rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.admin-user-actions{display:flex;align-items:flex-start}.admin-users-empty{margin-top:.75rem}.account-row{display:grid;grid-template-columns:auto minmax(0,1fr) auto;gap:.75rem;align-items:center;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:.85rem}.account-row p{margin:.3rem 0 0;color:var(--muted-strong);overflow-wrap:anywhere}.grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:.85rem}.item-list{margin:.75rem 0 0;padding-left:1.2rem}.item-list li{margin:.45rem 0}.panel dl:not(.dashboard-list){display:grid;grid-template-columns:max-content minmax(0,1fr);gap:.45rem .85rem}.panel dl:not(.dashboard-list) dt{font-weight:800}.panel dl:not(.dashboard-list) dd{margin:0;overflow-wrap:anywhere}table{width:100%;border-collapse:collapse}td,th{border-bottom:1px solid var(--border);text-align:left;padding:.55rem;vertical-align:top}pre{white-space:pre-wrap;overflow:auto;max-width:100%}
+.settings-media-frame{position:relative;min-width:0}.settings-picture-row{display:flex;align-items:flex-end;gap:0}.settings-picture-wrap{display:inline-block;max-width:100%;line-height:0}.settings-picture-preview{display:block}.settings-media-actions{position:absolute;z-index:2;display:flex;gap:.35rem;align-items:center}.settings-banner-actions{top:.55rem;right:.55rem}.settings-picture-actions{left:50%;bottom:.45rem;transform:translateX(-50%)}.settings-media-control{position:relative;display:inline-flex}.settings-media-input,.settings-media-delete-input{position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.settings-media-icon-button{display:inline-flex;align-items:center;justify-content:center;width:2rem;height:2rem;margin:0;border:1px solid rgba(255,255,255,.62);border-radius:999px;background:rgba(23,32,23,.56);color:#fff;padding:0;box-shadow:0 1px 4px rgba(0,0,0,.22);cursor:pointer;opacity:.72;transition:opacity .15s ease,background-color .15s ease,border-color .15s ease,transform .15s ease}.settings-media-icon-button svg{width:1rem;height:1rem;fill:currentColor}.settings-media-frame:hover .settings-media-icon-button,.settings-media-frame:focus-within .settings-media-icon-button,.settings-media-icon-button:hover{opacity:1}.settings-media-icon-button:hover{background:rgba(23,32,23,.82);text-decoration:none}.settings-media-input:focus-visible+.settings-media-icon-button,.settings-media-delete-input:focus-visible+.settings-media-icon-button{outline:3px solid var(--focus);outline-offset:2px;opacity:1}.settings-media-delete-input:checked+.settings-media-icon-button,.settings-media-removing .settings-media-remove{background:var(--danger);border-color:var(--danger-border);color:var(--brand-text);opacity:1}.settings-media-has-file .settings-media-change{background:var(--brand);border-color:rgba(255,255,255,.72);color:var(--brand-text);opacity:1}.settings-media-disabled{position:absolute;right:.55rem;bottom:.55rem;max-width:calc(100% - 1.1rem);margin:0;border:1px solid rgba(255,255,255,.5);border-radius:999px;background:rgba(23,32,23,.62);color:#fff;padding:.22rem .55rem;font-size:.82rem;font-weight:800;line-height:1.2;overflow-wrap:anywhere}
+.notification-row:focus-visible{outline:3px solid var(--focus);outline-offset:2px}.notification-open{min-height:2.25rem;padding:.35rem .6rem}.notification-actors summary{min-height:2.1rem}.post-permalink{font-weight:700;color:var(--link-strong)}.js-enabled .post-permalink{display:inline-flex}.js-enabled .post-permalink:not(:focus-visible){position:absolute;width:1px;height:1px;padding:0;margin:-1px;overflow:hidden;clip:rect(0,0,0,0);white-space:nowrap;border:0}.js-enabled .post-permalink:focus-visible{display:inline-flex;align-items:center;position:absolute;right:.85rem;bottom:.85rem;z-index:2;min-height:2.25rem;border:1px solid var(--border-strong);border-radius:999px;background:var(--surface);padding:.3rem .65rem;box-shadow:0 6px 18px var(--shadow);text-decoration:none}.icon-button{width:2.45rem;height:2.45rem}.admin-nsfw-button{min-height:2.45rem;padding:.35rem .6rem}.thread-back{width:2.4rem;height:2.4rem}button,.primary{min-height:2.5rem;padding:.52rem .82rem}input[type=checkbox]{width:1.05rem;height:1.05rem;accent-color:var(--brand)}input[type=file]{max-width:100%;color:var(--muted-strong)}input[type=file]::file-selector-button{min-height:2.15rem;border:1px solid var(--border);border-radius:7px;background:var(--surface);color:var(--link-strong);padding:.34rem .58rem;font-weight:800;cursor:pointer}input[type=file]::file-selector-button:hover{background:var(--hover);color:var(--text-strong)}label:has(>input[type=checkbox]){display:flex;align-items:flex-start;gap:.55rem;min-height:2.4rem;margin:.75rem 0 .35rem}label:has(>input[type=checkbox]) input[type=checkbox]{flex:0 0 auto;margin-top:.22rem}.empty-state{padding:2.2rem 1rem}.danger-zone{border-color:var(--danger-border);background:var(--danger-bg)}.danger-zone h2,.danger-zone h3{color:var(--danger-strong)}.admin-nav-grid{grid-template-columns:repeat(auto-fit,minmax(13rem,1fr))}.admin-nav-card{position:relative;display:grid;align-content:center;min-height:4.2rem;padding-right:2.3rem;color:var(--link-strong);font-weight:850}.admin-nav-card::after{content:"";position:absolute;right:1rem;top:50%;width:.48rem;height:.48rem;border-top:2px solid currentColor;border-right:2px solid currentColor;transform:translateY(-50%) rotate(45deg);opacity:.62}.admin-nav-card:hover{border-color:var(--border-strong);background:var(--hover);text-decoration:none}.admin-nav-card:focus-visible::after,.admin-nav-card:hover::after{opacity:1}
+.profile-secondary button{min-height:2.4rem}.settings-media-icon-button{width:2.25rem;height:2.25rem}.settings-media-icon-button svg{width:1.08rem;height:1.08rem}
+.counts a{color:var(--muted);font-weight:700;text-decoration:none}.counts a:hover{color:var(--link-strong);text-decoration:underline}
+.profile-title-row>div{min-width:0}.profile-title-row h1{margin:0;overflow-wrap:anywhere}.profile-bio{margin:.55rem 0 0;overflow-wrap:anywhere}.profile-state-note{color:var(--muted-strong);font-weight:700}
+.notice.info{border-color:var(--border-strong);background:var(--surface-subtle)}
+@media (prefers-reduced-motion:reduce){*,*::before,*::after{transition-duration:.01ms !important;animation-duration:.01ms !important}}
+.announcement{min-width:0;max-width:min(34rem,100%);color:var(--muted-strong);font-size:.86rem;font-weight:600;line-height:1.25;overflow-wrap:anywhere;white-space:normal;flex:0 1 auto}
+.maintenance-notice{margin:0 0 var(--section-gap);border-color:var(--border-strong);background:var(--surface-subtle)}
+.account-notice{margin:0 0 var(--section-gap)}
+.follow-request-row{display:grid;gap:.55rem;border:1px solid var(--border);border-radius:8px;padding:.75rem;background:var(--surface)}
+.follow-request-row .actions{display:flex;gap:.45rem;flex-wrap:wrap;align-items:center}
+.username-history{margin:.35rem 0 0;color:var(--muted);font-size:.9rem}
+.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/docs/README.md b/docs/README.md
new file mode 100644
index 0000000..0d9e3c5
--- /dev/null
+++ b/docs/README.md
@@ -0,0 +1,32 @@
+# RustPost guides
+
+Start with [Getting started](getting-started.md) to download RustPost, run it, and create your first administrator. You can read the administration and development references when you need them.
+
+## Start and use a site
+
+- [Getting started](getting-started.md): download, first run, checksums, and troubleshooting.
+- [Using RustPost](account-and-instance-features.md): posts, profiles, follows, account transfers, and deletion.
+- [Docker](docker.md): a container with persistent storage and media tools included.
+- [Local demo](demo-preview.md): sample accounts and screenshot reproduction.
+
+## Look after a site
+
+- [Operator guide](operator-guide.md): settings, users, storage, backups, Tor, and command reference.
+- [Deployment](deployment.md): public access, HTTPS, proxy behavior, and security notes.
+- [Software updates](software-updates.md): release checks and manual installation.
+- [Terminal console](terminal-console.md): controls, status, logs, and their limits.
+
+## Build and understand the implementation
+
+- [Development](development.md): current toolchain, checks, source map, and release packaging.
+- [Configuration reference](admin-configuration-audit.md): the complete settings inventory and web editor contract.
+- [Account behavior reference](account-reference.md): archive format, account rules, and implementation details.
+- [Compiler upgrade history](rust-1.99-upgrade.md): historical migration notes, separated from current build instructions.
+- [Changelog](../CHANGELOG.md): release changes and previous versions.
+- [License](../LICENSE): RustPost's MIT license.
+
+## Which version do these guides describe?
+
+The repository guides describe **v1.1.0**. Older **v1.0.0** binaries and containers predate the live terminal console, offline-only restore, simplified release checker, and final CSRF fixes. Record the source commit when building from `dev`; the package version alone does not identify a development build.
+
+The [download instructions](getting-started.md) describe v1.1.0 ZIPs and checksums. Use the published release page to confirm availability; preparation on `dev` does not publish a release. [Release packaging](development.md#release-packaging) also records the older v1.0.0 archive names. Historical changelog entries describe their own versions, not current guarantees.
diff --git a/docs/account-and-instance-features.md b/docs/account-and-instance-features.md
index 6335a24..aa10ce5 100644
--- a/docs/account-and-instance-features.md
+++ b/docs/account-and-instance-features.md
@@ -1,288 +1,63 @@
-# Account lifecycle, social approval, and instance controls
+# Using RustPost
-This document describes the behavior added for:
+RustPost is a community website for posts, media, and conversations. Log in to write posts and follow people; use **Settings** to manage your own account. Administrators also see **Admin** for site-wide controls.
-- [#26 Protected accounts](https://github.com/csd113/RustPost/issues/26)
-- [#27 Instance-wide announcements](https://github.com/csd113/RustPost/issues/27)
-- [#28 Maintenance mode](https://github.com/csd113/RustPost/issues/28)
-- [#29 Account export and import](https://github.com/csd113/RustPost/issues/29)
-- [#31 Admin forced password reset](https://github.com/csd113/RustPost/issues/31)
-- [#32 Admin forced logout](https://github.com/csd113/RustPost/issues/32)
-- [#33 Account deletion grace period](https://github.com/csd113/RustPost/issues/33)
-- [#34 Username change and username history](https://github.com/csd113/RustPost/issues/34)
+[All guides](README.md) · [Install RustPost](getting-started.md) · [Detailed account rules](account-reference.md)
-All state is stored in the SQLite database (schema version 4) so it survives
-restarts and is covered by instance backups. New routes use the existing
-server-rendered forms, CSRF tokens, and no-JavaScript fallbacks.
+## Write and read posts
-## Protected accounts (#26)
+Write in the Home Feed's post box. The default limit is 280 characters, and the site owner can change it. Attach images or videos if you want, then publish. Open a post to read the conversation and reply.
-- Each account has a persistent `Require approval for new followers`
- preference on the settings page (`users.follow_approval_required`).
-- Following a protected account creates a row in `follow_requests` and sends
- the owner a `follow_request` notification. A pending request is **not** a
- follow: it does not appear in follower counts or lists, does not grant feed
- privileges, and does not create a `follows` row.
-- The owner sees incoming requests at `/follow-requests` and can **Approve**
- or **Reject** them. Approving creates the follow and notifies the requester
- (`follow_request_approved`). Rejecting removes the request without a
- notification. Requesters can cancel their own pending request from the same
- page, from the profile button ("Requested"), or by unfollowing.
-- Enforcement: self-follows are rejected; suspended or deleted targets cannot
- be followed; a block in either direction removes pending requests in both
- directions and prevents new ones; duplicate and concurrent requests are
- idempotent through composite primary keys.
-- Enabling protection **preserves existing followers**. Disabling protection
- leaves already-pending requests pending — the owner can still approve or
- reject them, and new follows are accepted immediately. Nothing is silently
- auto-approved.
-- Pending requests are cleaned up when the requester or target account is
- deleted.
+Use **Following** to read people you follow. Profiles have tabs for posts, replies, media, and liked posts. You can like, repost, quote, or bookmark a post; bookmarks are private. Your profile settings control whether your liked posts are public.
-## Instance announcements (#27)
+You can edit your own post text for 15 seconds after publishing by default. The site owner can shorten, extend (up to five minutes), or disable that window. Edited posts are marked. You can also pin a post to your profile.
-- Administrators manage the announcement on the admin dashboard
- (`/admin`, "Announcement" panel).
-- An enabled, non-empty announcement renders in the top bar next to the
- RustPost/site name. On narrow screens it wraps onto its own line under the
- brand row.
-- Announcement text is limited to 280 characters, escapes control characters
- (newlines and tabs are allowed), and is HTML-escaped when rendered.
-- Stored in the `instance_settings` table, so it survives restarts, backup,
- and restore. An empty or disabled announcement renders nothing.
-- The "Clear announcement" button disables and empties the announcement in one
- step.
+Search finds posts and accounts; notifications collect activity involving you. Core forms work without JavaScript, though enhancements such as mention suggestions require it.
-## Maintenance mode (#28)
+## Set up your profile
-- Toggled by administrators on the admin dashboard (`/admin`, "Maintenance
- mode" panel) with an optional message (280 characters, HTML-escaped).
-- While enabled, these state-changing routes return `503` with the maintenance
- message:
- - `POST /register` (registration) — for everyone.
- - `POST /posts` (new posts **and** replies) — except administrators.
- - `POST /posts/{id}/edit` — except administrators. Editing is blocked so a
- draft written before maintenance cannot be published by rewriting it.
- - `POST /posts/{id}/quote` — except administrators.
- - `POST /posts/{id}/repost` — except administrators.
- - `POST /settings/import` (importing an archive publishes posts) — for
- everyone except administrators.
-- The policy lives in one table (`maintenance_policy` in `src/server.rs`) with
- a table-driven test covering every publish route, method, and role, so new
- routes cannot silently bypass maintenance checks.
-- Administrator exemption: administrators can still post, reply, quote,
- repost, edit, and import so they can verify the instance. They also keep full
- access to the admin UI to disable maintenance mode. Registration is not
- exempt because new accounts are not part of instance verification.
-- The site stays readable: all `GET` pages, logins, sessions, likes,
- bookmarks, follows, blocks, mutes, account settings, password changes,
- exports, and account deletion flows keep working. Maintenance mode does not
- silently disable security or account operations.
-- The composer/reply form is not rendered for visitors who cannot post, and
- every page shows a maintenance banner with the configured message.
+Open **Settings** to change your display name, bio, location, website, picture, banner, theme, and media-blur preferences. A username is your account handle; a display name is the name shown beside your posts.
-## Account export and import (#29)
+To change your username, use the **Username** panel and confirm your current password. Your posts, follows, and account identity stay with you. Previous usernames remain reserved while the account exists, and old profile URLs show history rather than silently becoming another person's profile. See [Username history](account-reference.md#username-changes-and-history-34).
-RustPost account archives are separate from full-instance backups. Export is
-owner-only (`GET /settings/export`); import writes into the authenticated
-destination account (`GET`/`POST /settings/import`) and never creates accounts.
+## Choose who you follow and what you see
-### Archive format (version 1)
+Visit a profile to follow it. If that account requires approval, your request remains pending until its owner accepts it.
-A gzip-compressed tar archive (`.tar.gz`) containing:
+To require approval for your own new followers, enable **Require approval for new followers** in Settings. Review requests on **Follow requests**. Existing followers stay approved. Turning the preference off does not automatically approve outstanding requests; you can still accept or reject them. This controls following; it is not a promise that your posts are private or hidden from public visitors.
-| Entry | Contents |
+| Control | What it does |
| --- | --- |
-| `manifest.json` | format version, app name, creation timestamp, random archive id, archive handle |
-| `profile.json` | display name, bio, location, website, theme, NSFW blur, liked-posts visibility, follow-approval preference |
-| `posts.json` | the account's posts, replies, and quotes with archive-local ids and remapped parent/root/quote references |
-| `media.json` | media metadata (kind, MIME type, filename, byte length, alt text, NSFW flag, SHA-256) |
-| `media/...` | media bytes for every entry in `media.json` (yes, bytes are included) |
-| `follows.json` | outgoing follows as portable handle references with timestamps |
-| `settings.json` | muted words |
+| Block | Removes follows in both directions and prevents interactions between the accounts. Their posts are hidden from one another's normal views. Unblocking allows interaction again but does not recreate removed follows. |
+| Mute | Hides an account's posts from your feeds, search, suggestions, notifications, and profile activity without notifying them. |
+| Muted words | Hides matching text in relevant feeds and previews. Matching ignores case and can match inside a word: `cat` also matches `concatenate`. Your own posts remain visible to you. |
-Limits: 300 MiB compressed per archive (upload and export output), 1 GiB of
-total decompressed media, 10,000 entries, 5,000 posts, 1,000 media, 5,000
-follows, 500 muted words. The compressed and expanded ceilings are independent:
-raising the upload limit never raises the decompressed limit, and extraction
-enforces the expanded limit incrementally. Operators can tune all three archive
-ceilings in `settings.toml` under `[accounts]`:
+These preferences do not erase content from the database. An account being deleted or a post being removed has separate rules.
-```toml
-[accounts]
-max_archive_upload_bytes = 314572800 # compressed, also exported size
-max_archive_expanded_bytes = 1073741824 # total decompressed media bytes
-max_archive_entries = 10000
-```
+## Export or import your account
-Media bytes are hashed and verified during import, and declared media metadata
-(byte length, SHA-256, MIME type/kind) must agree with the actual extracted
-bytes; unrecognized or mismatched media content is rejected.
+Use the account export/import controls in **Settings**. An export is a `.tar.gz` archive containing your profile information, posts, media, outgoing follows, and muted words. Keep it private: it contains your personal content.
-### Never included
+To import, first create or log in to the destination RustPost account, then open its import page and select your archive. Imports add content to that account; they do not create accounts or change the destination username. Non-empty destination profile fields are kept, and the result reports skipped items. Importing the same archive into the same account again is rejected.
-Password hashes, sessions, CSRF tokens, delete-intent tokens, other secrets,
-administrator flags, suspension/deletion state, forced password-reset state,
-instance configuration, other accounts' posts/media, notifications, likes,
-bookmarks, reposts, and account-import records for the exported account.
+Follows can only reconnect to accounts that exist on the destination site. Protected accounts still require approval. Passwords, sessions, administrator access, notifications, likes, bookmarks, and reposts are not transferred. References to other people's posts may not survive a transfer.
-Import summaries report what was skipped: follow targets that are missing,
-deleted, suspended, self, or blocked, duplicate/pending follow states, and
-reply/quote references that could not be remapped. A post whose media list
-references media that is not part of the archive is rejected during validation
-instead of silently importing an incomplete post.
+The default compressed archive limit is 300 MiB; the owner can change it. See the [archive format and import rules](account-reference.md#account-export-and-import-29) for the full inclusion list, independent expansion limits, and validation behavior.
-### Import rules
+An account export is **not a full-site backup**. Administrators should use [Backups](operator-guide.md#backup-and-restore) to preserve the whole community.
-- The destination account is always the authenticated account that performs
- the import. Archive contents can never claim another account, change the
- destination username, grant administrator status, resurrect sessions, or
- clear forced-reset/deletion restrictions.
-- The complete archive is validated before any database or filesystem
- mutation: format version, JSON schema, per-record limits, path safety
- (no absolute paths, `..`, backslashes, encoded traversal, non-ASCII or
- case-colliding entry names, links, device files, or duplicates), media
- hashes/lengths/content type, and both archive size ceilings.
-- Structural limits are enforced incrementally while the gzip/tar stream is
- read: entry count, per-entry declared size, total decompressed media bytes,
- and cumulative JSON document bytes. A tiny compressed archive can never
- expand into unbounded memory or disk.
-- The upload is streamed to a staging file with the compressed limit applied
- for every chunk; an oversized archive gets a `413` page naming the configured
- limit instead of a generic transport failure.
-- Media files are staged and installed before the database transaction
- commits; failures remove copied files, and the whole import is recorded in
- `account_imports` so re-importing the same archive into the same account is
- rejected instead of duplicating content.
-- Profile fields (display name, bio, location, website) fill only empty
- destination fields; non-empty destination values win and are counted as
- skipped. Theme, NSFW blur, liked-posts visibility, and follow-approval
- preference are applied from the archive. Usernames are reported but never
- changed.
-- Posts are inserted with remapped ids. Reply/quote relationships to posts
- that are not part of the archive are dropped. Reposts and likes/bookmarks of
- other accounts' posts are not portable and are not imported.
-- Follows resolve by canonical handle on the destination instance:
- - existing, active accounts become follows;
- - protected accounts become **pending follow requests** — import never
- bypasses approval;
- - missing, deleted, suspended, self, or blocked accounts are skipped and
- counted in the import report.
-- JSON fields such as `is_admin`, `is_suspended`,
- `must_change_password`, `deletion_scheduled_at`, or `password_hash` are
- ignored; they can never synthesize privileged or restricted destination
- state.
-- Media bytes are copied into the destination uploads directories with new
- unique names. If an identical canonical media file already exists on the
- destination, the import records a duplicate row pointing at the existing
- file instead of copying bytes again.
-- Imports are blocked for non-administrators while maintenance mode is active
- and share the posting rate-limit budget. Accounts that are restricted to a
- forced password change or pending deletion cannot start an import at all, so
- an archive can never lift those restrictions.
+## Change your password or delete your account
-### Nonportable data
+Change your password in **Settings** using your current password and a new one. An administrator can require you to change it at your next authenticated visit, or sign all of your sessions out. A forced logout does not itself prevent you from logging in again.
-Reposts, likes, bookmarks, notifications, sessions, passwords, administrator
-privileges, private/deleted account state, other accounts' content, instance
-configuration, and Tor keys are not ported by account archives. Follows are
-resolved against accounts that already exist on the destination instance;
-RustPost is non-federated and does not invent remote accounts.
+To delete your account, use the deletion flow in Settings and confirm your password. By default, you have **30 days to cancel**. The page shows the deadline; the owner can configure another grace period, including immediate deletion.
-## Admin forced password reset (#31)
+While deletion is pending, you can log in, read, export your data, change your password, log out, or cancel deletion. Publishing and social actions are restricted. After final deletion, the account's content and relationships are removed; shared media still referenced by another account is retained. Old usernames can be registered again, with a notice identifying the new holder as a different account. See [Deletion rules](account-reference.md#account-deletion-grace-period-33).
-- `POST /admin/users/{id}/require-password-reset` sets
- `users.must_change_password` and writes an audit row.
-- After that, every authenticated request (including existing sessions) is
- redirected to `/settings/password?required=1` until the password changes.
- The only reachable flows are the password change, logout, account deletion,
- and account export, plus static assets. If the account state cannot be read,
- the request fails closed instead of continuing.
-- The password page renders only the password form and a logout button while
- the restriction is active.
-- Passwords are validated with the existing policy and hashed with the
- existing Argon2id mechanism. The flag is cleared in the same database
- transaction that stores the new hash, and all other sessions are revoked
- exactly as in a normal password change.
-- Repeating the admin action is harmless; a forced logout (`#32`) does not
- clear the requirement.
+## Community announcements and maintenance
-## Admin forced logout (#32)
+Administrators can publish an announcement from **Admin**. It appears near the site name and can be cleared or disabled there.
-- `POST /admin/users/{id}/revoke-sessions` marks every active session of the
- selected account revoked and writes an audit row. The action reports how many
- sessions were revoked.
-- Revoked cookies stop authenticating on the next request across all protected
- routes; other accounts' sessions are unaffected, and the account can log in
- again unless another restriction (suspension, pending deletion) applies.
-- Repeated revocation is safe: it simply reports zero remaining sessions.
+Maintenance mode pauses registration and ordinary users' publishing while the site remains readable. Logins, likes, bookmarks, follows, settings, exports, and account deletion remain available. Administrators can still publish to verify the site. Maintenance mode is not a shutdown and does not make the database safe to replace: [stop the server before restoring](operator-guide.md#backup-and-restore).
-## Account deletion grace period (#33)
-
-- Configuration: `[accounts] deletion_grace_period_days` in `settings.toml`,
- default **30 days**, maximum 3650, `0` means immediate deletion after
- password confirmation. Documented in the generated `settings.toml`.
-- The delete flow (`/settings/delete` → `/settings/delete/confirm`) verifies
- the password and stores `deletion_requested_at` and `deletion_scheduled_at`
- instead of deleting immediately. The user sees the deadline and a cancel
- button on the settings page, plus a site-wide banner while the deletion is
- pending.
-- Access policy while a deletion is pending: the account can still log in,
- read, export its archive, change its password, log out, and cancel the
- deletion. Publishing (posts, replies, quotes, reposts), imports, profile
- edits, and social actions are refused with a clear message until the
- deletion is cancelled.
-- Permanent removal uses persisted timestamps and runs from the periodic
- maintenance task (also on startup), so it survives restarts and does not
- depend on an in-memory timer. Finalization is idempotent and re-checks the
- deadline and cancellation inside one transaction, so concurrent cancellation
- wins safely.
-- Finalization runs the same scrubbing logic as password-confirmed deletion:
- posts, reposts, likes, bookmarks, follows, blocks, mutes, follow requests,
- notifications, reports, sessions, rate-limit rows, uploaded media owned by
- the account, and shared media references are handled exactly as before.
- Media still referenced by other accounts is preserved.
-- Username reservations are released on permanent deletion: `username_history`
- rows for the account are removed so the handles become available again. This
- is a deliberate policy choice for a small, self-hosted instance; historical
- handles of active accounts stay reserved.
-- Deletion records anonymous release tombstones (`released_username:`
- rows in `instance_settings`) for the handles it frees. A handle that no
- account owns renders a tombstone page instead of a bare 404, and if someone
- registers a released handle, the new profile is marked as a different
- account. This keeps the username-release policy while ensuring an old
- profile URL can never silently masquerade as the deleted account.
-- Finalization writes a durable pending-media-deletion journal before the
- database transaction commits and removes files afterwards, deleting the
- journal only when every file is gone. A crash between commit and file
- removal is recovered from the journal at startup, so the process is
- restart-safe and idempotent without depending on in-memory state.
-- Database/filesystem failures during finalization are logged, leave the
- account intact for retry, and never leave a partially deleted account.
-
-## Username changes and history (#34)
-
-- `POST /settings/username` (settings page "Username" panel) requires the
- current password. New handles are validated with the shared username rules
- (allowed characters, length, reserved names) and canonicalized
- case-insensitively.
-- Uniqueness is enforced atomically in one transaction against both the
- current `users.normalized_username` values and `username_history`, so
- concurrent changes and registrations cannot claim the same handle.
-- The previous handle is recorded in `username_history` with a timestamp.
- Internal identity (user id), posts, follows, permissions, sessions, blocked
- lists, exports, and deletion state are all keyed by user id and are
- unaffected.
-- Another account can never claim a handle that appears in history. The same
- account may switch back to a handle it previously used; the stale history
- row for that handle is replaced by the newer previous name.
-- Profile pages show a "Previously known as @…" note for accounts with
- history. Requesting a handle that no account currently owns but that has
- history renders a "no account uses this handle" page listing the previous
- holders with links — old profile links and `@mentions` never silently
- resolve to a different person or redirect to a new owner.
-- A handle released by permanent deletion becomes claimable again, but the
- deletion records an anonymous release tombstone. Old URLs render a
- "released handle" page while the handle is unclaimed, and a profile that
- claims a released handle is explicitly marked as a different account, so an
- old link can never appear to be the deleted account.
+Administrators can find day-to-day controls in the [operator guide](operator-guide.md). Developers and operators needing precise restrictions can read the [account behavior reference](account-reference.md).
diff --git a/docs/account-reference.md b/docs/account-reference.md
new file mode 100644
index 0000000..a6539f2
--- /dev/null
+++ b/docs/account-reference.md
@@ -0,0 +1,287 @@
+# Account behavior reference
+
+This reference explains the rules behind account and community controls. For the browser steps, start with [Using RustPost](account-and-instance-features.md).
+
+[All guides](README.md) · [Configuration](admin-configuration-audit.md) · [Site backups](operator-guide.md#backup-and-restore)
+
+Account state is stored in SQLite and persists across restarts. Account archives carry selected personal content; full-site backups include the site's database and settings. The numbered issue links below preserve the origin of each feature.
+
+## Feature origins
+
+The behavior below originated in issues [#26](https://github.com/csd113/RustPost/issues/26) (follow approval), [#27](https://github.com/csd113/RustPost/issues/27) (announcements), [#28](https://github.com/csd113/RustPost/issues/28) (maintenance), [#29](https://github.com/csd113/RustPost/issues/29) (account archives), [#31](https://github.com/csd113/RustPost/issues/31) (password reset), [#32](https://github.com/csd113/RustPost/issues/32) (logout), [#33](https://github.com/csd113/RustPost/issues/33) (deletion), and [#34](https://github.com/csd113/RustPost/issues/34) (username history).
+
+## Protected accounts (#26)
+
+- Each account has a persistent `Require approval for new followers`
+ preference on the settings page (`users.follow_approval_required`).
+- Following a protected account creates a row in `follow_requests` and sends
+ the owner a `follow_request` notification. A pending request is **not** a
+ follow: it does not appear in follower counts or lists, does not grant feed
+ privileges, and does not create a `follows` row.
+- The owner sees incoming requests at `/follow-requests` and can **Approve**
+ or **Reject** them. Approving creates the follow and notifies the requester
+ (`follow_request_approved`). Rejecting removes the request without a
+ notification. Requesters can cancel their own pending request from the same
+ page, from the profile button ("Requested"), or by unfollowing.
+- Enforcement: self-follows are rejected; suspended or deleted targets cannot
+ be followed; a block in either direction removes pending requests in both
+ directions and prevents new ones; duplicate and concurrent requests are
+ idempotent through composite primary keys.
+- Enabling protection **preserves existing followers**. Disabling protection
+ leaves already-pending requests pending — the owner can still approve or
+ reject them, and new follows are accepted immediately. Nothing is silently
+ auto-approved.
+- Pending requests are cleaned up when the requester or target account is
+ deleted.
+
+## Instance announcements (#27)
+
+- Administrators manage the announcement on the admin dashboard
+ (`/admin`, "Announcement" panel).
+- An enabled, non-empty announcement renders in the top bar next to the
+ RustPost/site name. On narrow screens it wraps onto its own line under the
+ brand row.
+- Announcement text is limited to 280 characters, escapes control characters
+ (newlines and tabs are allowed), and is HTML-escaped when rendered.
+- Stored in the `instance_settings` table, so it survives restarts, backup,
+ and restore. An empty or disabled announcement renders nothing.
+- The "Clear announcement" button disables and empties the announcement in one
+ step.
+
+## Maintenance mode (#28)
+
+- Toggled by administrators on the admin dashboard (`/admin`, "Maintenance
+ mode" panel) with an optional message (280 characters, HTML-escaped).
+- While enabled, these state-changing routes return `503` with the maintenance
+ message:
+ - `POST /register` (registration) — for everyone.
+ - `POST /posts` (new posts **and** replies) — except administrators.
+ - `POST /posts/{id}/edit` — except administrators. Editing is blocked so a
+ draft written before maintenance cannot be published by rewriting it.
+ - `POST /posts/{id}/quote` — except administrators.
+ - `POST /posts/{id}/repost` — except administrators.
+ - `POST /settings/import` (importing an archive publishes posts) — for
+ everyone except administrators.
+- The publishing policy is centralized in `maintenance_policy` in
+ `src/server.rs`; route guards apply it before accepting publishing changes.
+- Administrator exemption: administrators can still post, reply, quote,
+ repost, edit, and import so they can verify the instance. They also keep full
+ access to the admin UI to disable maintenance mode. Registration is not
+ exempt because new accounts are not part of instance verification.
+- The site stays readable: all `GET` pages, logins, sessions, likes,
+ bookmarks, follows, blocks, mutes, account settings, password changes,
+ exports, and account deletion flows keep working. Maintenance mode does not
+ silently disable security or account operations.
+- The composer/reply form is not rendered for visitors who cannot post, and
+ every page shows a maintenance banner with the configured message.
+
+## Account export and import (#29)
+
+RustPost account archives are separate from full-instance backups. Export is
+owner-only (`GET /settings/export`); import writes into the authenticated
+destination account (`GET`/`POST /settings/import`) and never creates accounts.
+
+### Archive format (version 1)
+
+A gzip-compressed tar archive (`.tar.gz`) containing:
+
+| Entry | Contents |
+| --- | --- |
+| `manifest.json` | format version, app name, creation timestamp, random archive id, archive handle |
+| `profile.json` | display name, bio, location, website, theme, NSFW blur, liked-posts visibility, follow-approval preference |
+| `posts.json` | the account's posts, replies, and quotes with archive-local ids and remapped parent/root/quote references |
+| `media.json` | media metadata (kind, MIME type, filename, byte length, alt text, NSFW flag, SHA-256) |
+| `media/...` | media bytes for every entry in `media.json` (yes, bytes are included) |
+| `follows.json` | outgoing follows as portable handle references with timestamps |
+| `settings.json` | muted words |
+
+Limits: 300 MiB compressed per archive (upload and export output), 1 GiB of
+total decompressed media, 10,000 entries, 5,000 posts, 1,000 media, 5,000
+follows, 500 muted words. The compressed and expanded ceilings are independent:
+raising the upload limit never raises the decompressed limit, and extraction
+enforces the expanded limit incrementally. Operators can tune all three archive
+ceilings in `settings.toml` under `[accounts]`:
+
+```toml
+[accounts]
+max_archive_upload_bytes = 314572800 # compressed, also exported size
+max_archive_expanded_bytes = 1073741824 # total decompressed media bytes
+max_archive_entries = 10000
+```
+
+Media bytes are hashed and verified during import, and declared media metadata
+(byte length, SHA-256, MIME type/kind) must agree with the actual extracted
+bytes; unrecognized or mismatched media content is rejected.
+
+### Never included
+
+Password hashes, sessions, CSRF tokens, delete-intent tokens, other secrets,
+administrator flags, suspension/deletion state, forced password-reset state,
+instance configuration, other accounts' posts/media, notifications, likes,
+bookmarks, reposts, and account-import records for the exported account.
+
+Import summaries report what was skipped: follow targets that are missing,
+deleted, suspended, self, or blocked, duplicate/pending follow states, and
+reply/quote references that could not be remapped. A post whose media list
+references media that is not part of the archive is rejected during validation
+instead of silently importing an incomplete post.
+
+### Import rules
+
+- The destination account is always the authenticated account that performs
+ the import. Archive contents can never claim another account, change the
+ destination username, grant administrator status, resurrect sessions, or
+ clear forced-reset/deletion restrictions.
+- The complete archive is validated before any database or filesystem
+ mutation: format version, JSON schema, per-record limits, path safety
+ (no absolute paths, `..`, backslashes, encoded traversal, non-ASCII or
+ case-colliding entry names, links, device files, or duplicates), media
+ hashes/lengths/content type, and both archive size ceilings.
+- Structural limits are enforced incrementally while the gzip/tar stream is
+ read: entry count, per-entry declared size, total decompressed media bytes,
+ and cumulative JSON document bytes. A tiny compressed archive can never
+ expand into unbounded memory or disk.
+- The upload is streamed to a staging file with the compressed limit applied
+ for every chunk; an oversized archive gets a `413` page naming the configured
+ limit instead of a generic transport failure.
+- Media files are staged and installed before the database transaction
+ commits; failures remove copied files, and the whole import is recorded in
+ `account_imports` so re-importing the same archive into the same account is
+ rejected instead of duplicating content.
+- Profile fields (display name, bio, location, website) fill only empty
+ destination fields; non-empty destination values win and are counted as
+ skipped. Theme, NSFW blur, liked-posts visibility, and follow-approval
+ preference are applied from the archive. Usernames are reported but never
+ changed.
+- Posts are inserted with remapped ids. Reply/quote relationships to posts
+ that are not part of the archive are dropped. Reposts and likes/bookmarks of
+ other accounts' posts are not portable and are not imported.
+- Follows resolve by canonical handle on the destination instance:
+ - existing, active accounts become follows;
+ - protected accounts become **pending follow requests** — import never
+ bypasses approval;
+ - missing, deleted, suspended, self, or blocked accounts are skipped and
+ counted in the import report.
+- JSON fields such as `is_admin`, `is_suspended`,
+ `must_change_password`, `deletion_scheduled_at`, or `password_hash` are
+ ignored; they can never synthesize privileged or restricted destination
+ state.
+- Media bytes are copied into the destination uploads directories with new
+ unique names. If an identical canonical media file already exists on the
+ destination, the import records a duplicate row pointing at the existing
+ file instead of copying bytes again.
+- Imports are blocked for non-administrators while maintenance mode is active
+ and share the posting rate-limit budget. Accounts that are restricted to a
+ forced password change or pending deletion cannot start an import at all, so
+ an archive can never lift those restrictions.
+
+### Nonportable data
+
+Reposts, likes, bookmarks, notifications, sessions, passwords, administrator
+privileges, private/deleted account state, other accounts' content, instance
+configuration, and Tor keys are not ported by account archives. Follows are
+resolved against accounts that already exist on the destination instance;
+RustPost is non-federated and does not invent remote accounts.
+
+## Admin forced password reset (#31)
+
+- `POST /admin/users/{id}/require-password-reset` sets
+ `users.must_change_password` and writes an audit row.
+- After that, every authenticated request (including existing sessions) is
+ redirected to `/settings/password?required=1` until the password changes.
+ The only reachable flows are the password change, logout, account deletion,
+ and account export, plus static assets. If the account state cannot be read,
+ the request fails closed instead of continuing.
+- The password page renders only the password form and a logout button while
+ the restriction is active.
+- Passwords are validated with the existing policy and hashed with the
+ existing Argon2id mechanism. The flag is cleared in the same database
+ transaction that stores the new hash, and all other sessions are revoked
+ exactly as in a normal password change.
+- Repeating the admin action is harmless; a forced logout (`#32`) does not
+ clear the requirement.
+
+## Admin forced logout (#32)
+
+- `POST /admin/users/{id}/revoke-sessions` marks every active session of the
+ selected account revoked and writes an audit row. The action reports how many
+ sessions were revoked.
+- Revoked cookies stop authenticating on the next request across all protected
+ routes; other accounts' sessions are unaffected, and the account can log in
+ again unless another restriction (suspension, pending deletion) applies.
+- Repeated revocation is safe: it simply reports zero remaining sessions.
+
+## Account deletion grace period (#33)
+
+- Configuration: `[accounts] deletion_grace_period_days` in `settings.toml`,
+ default **30 days**, maximum 3650, `0` means immediate deletion after
+ password confirmation. Documented in the generated `settings.toml`.
+- The delete flow starts at `GET /settings/delete`. Its CSRF-protected
+ `POST /settings/delete/prepare` displays the final password form with a
+ session-bound, single-use intent that expires after ten minutes. This works
+ without JavaScript. `GET` and `HEAD /settings/delete/confirm` show the initial
+ warning without replacing an outstanding intent, so old links remain safe.
+ The final `POST /settings/delete/confirm` verifies
+ the password and stores `deletion_requested_at` and `deletion_scheduled_at`
+ instead of deleting immediately. The user sees the deadline and a cancel
+ button on the settings page, plus a site-wide banner while the deletion is
+ pending.
+- Access policy while a deletion is pending: the account can still log in,
+ read, export its archive, change its password, log out, and cancel the
+ deletion. Publishing (posts, replies, quotes, reposts), imports, profile
+ edits, and social actions are refused with a clear message until the
+ deletion is cancelled.
+- Permanent removal uses persisted timestamps and runs from the periodic
+ maintenance task (also on startup), so it survives restarts and does not
+ depend on an in-memory timer. Finalization is idempotent and re-checks the
+ deadline and cancellation inside one transaction, so concurrent cancellation
+ wins safely.
+- Finalization runs the same scrubbing logic as password-confirmed deletion:
+ posts, reposts, likes, bookmarks, follows, blocks, mutes, follow requests,
+ notifications, reports, sessions, rate-limit rows, uploaded media owned by
+ the account, and shared media references are handled exactly as before.
+ Media still referenced by other accounts is preserved.
+- Username reservations are released on permanent deletion: `username_history`
+ rows for the account are removed so the handles become available again. This
+ is a deliberate policy choice for a small, self-hosted instance; historical
+ handles of active accounts stay reserved.
+- Deletion records anonymous release tombstones (`released_username:`
+ rows in `instance_settings`) for the handles it frees. A handle that no
+ account owns renders a tombstone page instead of a bare 404, and if someone
+ registers a released handle, the new profile is marked as a different
+ account. This keeps the username-release policy while ensuring an old
+ profile URL can never silently masquerade as the deleted account.
+- Finalization writes a durable pending-media-deletion journal before the
+ database transaction commits and removes files afterwards, deleting the
+ journal only when every file is gone. A crash between commit and file
+ removal is recovered from the journal at startup, so the process is
+ restart-safe and idempotent without depending on in-memory state.
+- Database/filesystem failures during finalization are logged, leave the
+ account intact for retry, and never leave a partially deleted account.
+
+## Username changes and history (#34)
+
+- `POST /settings/username` (settings page "Username" panel) requires the
+ current password. New handles are validated with the shared username rules
+ (allowed characters, length, reserved names) and canonicalized
+ case-insensitively.
+- Uniqueness is enforced atomically in one transaction against both the
+ current `users.normalized_username` values and `username_history`, so
+ concurrent changes and registrations cannot claim the same handle.
+- The previous handle is recorded in `username_history` with a timestamp.
+ Internal identity (user id), posts, follows, permissions, sessions, blocked
+ lists, exports, and deletion state are all keyed by user id and are
+ unaffected.
+- Another account can never claim a handle that appears in history. The same
+ account may switch back to a handle it previously used; the stale history
+ row for that handle is replaced by the newer previous name.
+- Profile pages show a "Previously known as @…" note for accounts with
+ history. Requesting a handle that no account currently owns but that has
+ history renders a "no account uses this handle" page listing the previous
+ holders with links — old profile links and `@mentions` never silently
+ resolve to a different person or redirect to a new owner.
+- A handle released by permanent deletion becomes claimable again, but the
+ deletion records an anonymous release tombstone. Old URLs render a
+ "released handle" page while the handle is unclaimed, and a profile that
+ claims a released handle is explicitly marked as a different account, so an
+ old link can never appear to be the deleted account.
diff --git a/docs/admin-configuration-audit.md b/docs/admin-configuration-audit.md
new file mode 100644
index 0000000..59917ec
--- /dev/null
+++ b/docs/admin-configuration-audit.md
@@ -0,0 +1,119 @@
+# Configuration reference
+
+Most site settings can be changed in **Admin → Deep server settings**. Change a value, review the summary, then confirm or discard it. Start with the [operator guide](operator-guide.md#configuration) for everyday changes; this page lists every setting and explains how the editor saves them.
+
+[All guides](README.md) · [Settings inventory](#complete-toml-inventory) · [Editor behavior](#how-the-editor-saves-changes) · [Deployment](deployment.md)
+
+## Choose where to change a setting
+
+The model contains **65 TOML options**: 61 appear in the web editor, and four remain deployment-managed. The admin overview also manages four database values (announcement text/enabled and maintenance message/enabled) plus the favicon file. Account preferences are managed from each account's Settings page.
+
+The four file-only fields are `media.ffmpeg_path`, `tor.data_dir`, `backup.backup_dir`, and `tor.include_tor_keys_in_backups_by_default`. Executable selection and moving private storage require access to the host; the last field must remain false because manual key inclusion requires explicit consent.
+
+## Sources and timing
+
+`src/config.rs` defines the model, defaults, validation, and generated commented TOML. `src/config/admin_fields.rs` defines editor fields. Actual runtime consumers determine behavior: some older generated/editor help text still describes proxy trust as unused or a manually supplied onion display address as accepted. **Current code uses trusted proxies and rejects a non-empty onion display address**; the corrected behavior is documented here and in [Deployment](deployment.md#https-and-proxy-settings).
+
+The CLI's `--config` selects a settings file; `--data-dir` selects storage. Neither overrides individual settings. There are no per-setting environment overrides. `RUST_LOG` selects tracing filters at startup. Debug builds also recognize `RUSTPOST_E2E_CAPTCHA_ANSWER` for controlled test fixtures; it is not an operator setting or release-mode CAPTCHA bypass.
+
+Startup settings apply after restart. The editor shows stored values alongside pending differences from the running state. The NSFW blur toggle saved through the editor applies immediately; backup policy is read by manual operations and on the scheduler's next check. Announcements and maintenance mode are live database values. `server.public_url` supplies display information while links remain relative; `posts.allow_hashtags` remains reserved and does not disable hashtag extraction.
+
+Missing backward-compatible fields use serde defaults; required fields remain required. An absent backup table defaults to `backups`, while an existing table omitting `backup_dir` retains the historical empty-path default (the data root). Preserve the distinction when editing old configurations.
+
+## How the editor saves changes
+
+Fields are grouped as Site, Accounts, Posts, Media, Networking & cookies, Rate limiting, Onion service, Backups, and Administration. Lists use one item per line; byte controls display exact MiB values. Category links and review/confirm/discard work without JavaScript; JavaScript adds search.
+
+The editor validates values before writing. It replaces changed TOML value spans while preserving unrelated keys, sections, comments, and unchanged formatting. Comments inside a replaced value are replaced with that value. Missing defaulted fields can be inserted. The writer uses a same-directory temporary file, inherited permissions, file synchronization, and atomic replacement; non-regular destination files are rejected.
+
+Web writers share a lock. Settings forms carry a revision of the file so an intervening edit requires fresh review instead of silently overwriting it. Preview and confirmation revalidate input; running startup settings remain unchanged until restart. Backup settings share the configuration writer.
+
+The typed field contract names actual model members and generates parsing and accessors. Historical coverage described in earlier versions of this page is not a claim about today's test suite; see [current checks](development.md#checks).
+
+## 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 | Editing / timing / bounds |
+|---|---|---|---|---|---|
+| `site.name` | `String` | `"RustPost"` | Yes | compression, server, db | 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 | Restart required; IP address to listen on. A restart can disconnect this console. |
+| `server.port` | `u16` | `8080` | No | tor, cli | 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 | 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 | Restart required; Enable only when visitors always use HTTPS. |
+| `server.trusted_proxy_cidrs` | `Vec` | `["127.0.0.1/32", "::1/128"]` | No | request provenance and client-IP resolution | Restart required; One IP/CIDR per line. Only listed direct peers can supply forwarded scheme and client-IP metadata; see the deployment guide. |
+| `accounts.registration_enabled` | `bool` | `true` | No | server | Restart required; Allow this feature. |
+| `accounts.registration_captcha_enabled` | `bool` | `false` | Yes | server | Restart required; Allow this feature. |
+| `accounts.anonymous_mode_enabled` | `bool` | `false` | No | social, server | Restart required; Allow this feature. |
+| `accounts.min_password_length` | `usize` | `10` | No | server, validation | Restart required; Characters. Recommended default is 10; 0 permits empty passwords. |
+| `accounts.max_username_len` | `usize` | `32` | No | portability, auth, identity | Restart required; Characters. |
+| `accounts.max_display_name_len` | `usize` | `64` | No | server, validation | Restart required; Characters. |
+| `accounts.max_bio_len` | `usize` | `240` | No | server, validation | Restart required; Characters. |
+| `accounts.allow_profile_banners` | `bool` | `true` | No | server | Restart required; Allow this feature. |
+| `accounts.allow_profile_pictures` | `bool` | `true` | No | server | Restart required; Allow this feature. |
+| `accounts.deletion_grace_period_days` | `u64` | `30` | Yes | server, account | Restart required; Days, 0–3650. Set 0 for immediate deletion. |
+| `accounts.max_archive_upload_bytes` | `u64` | `314572800` | Yes | portability, server | Restart required; MiB. Applies to imports and exports; 0.0625–2048 MiB. |
+| `accounts.max_archive_expanded_bytes` | `u64` | `1073741824` | Yes | portability, server | Restart required; MiB of decompressed media; 0.0625–16384 MiB. |
+| `accounts.max_archive_entries` | `usize` | `10000` | Yes | portability | Restart required; Tar entries per import, 1–100000. |
+| `posts.max_text_chars` | `usize` | `280` | No | portability, social, server | Restart required; Characters. |
+| `posts.post_edit_window_seconds` | `u64` | `15` | Yes | social, server | Restart required; Seconds, 0–300. Set 0 to disable editing. |
+| `posts.max_images_per_post` | `usize` | `4` | No | social, server | Restart required; Attachments per post. |
+| `posts.max_videos_per_post` | `usize` | `1` | No | social, server | Restart required; Attachments per post. |
+| `posts.max_media_per_post` | `usize` | `4` | No | social, server | Restart required; Attachments per post. |
+| `posts.allow_reposts` | `bool` | `true` | No | posts runtime | Restart required; Allow this feature. |
+| `posts.allow_replies` | `bool` | `true` | No | posts runtime | Restart required; Allow this feature. |
+| `posts.allow_likes` | `bool` | `true` | No | posts runtime | Restart required; Allow this feature. |
+| `posts.allow_bookmarks` | `bool` | `true` | No | posts runtime | Restart required; Allow this feature. |
+| `posts.allow_hashtags` | `bool` | `true` | No | Reserved; hashtag extraction is unconditional | Restart required; Reserved: hashtag extraction currently operates regardless of this flag. |
+| `posts.allow_mentions` | `bool` | `true` | No | social | Restart required; Allow this feature. |
+| `media.ffmpeg_path` | `String` | `"ffmpeg"` | No | ffmpeg | Deployment executable: arbitrary process selection with server privileges. |
+| `media.convert_images_to_webp` | `bool` | `true` | No | media | Restart required; Convert accepted images using the existing image processor. |
+| `media.convert_videos_to_webm` | `bool` | `true` | No | media runtime | Restart required; Convert accepted videos using FFmpeg. |
+| `media.keep_original_uploads` | `bool` | `false` | No | media | Restart required; Retain original uploads in addition to converted media. |
+| `media.nsfw_blur_enabled` | `bool` | `true` | Yes | portability, auth, server | Immediate; Blur flagged media unless a user disables their own blur setting. |
+| `media.max_image_size` | `u64` | `52428800` | No | server | Restart required; MiB per image (1 MiB = 1,048,576 bytes). Decimal values are accepted. |
+| `media.max_video_size` | `u64` | `157286400` | No | media, server | Restart required; MiB per video (1 MiB = 1,048,576 bytes). Decimal values are accepted. |
+| `media.generate_video_thumbnails` | `bool` | `true` | No | media runtime | Restart required; Use FFmpeg to extract video previews. |
+| `media.allowed_image_mime_types` | `Vec` | `["image/jpeg", "image/png", "image/gif", "image/webp"]` | No | portability | 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 | Restart required; One MIME type per line, for example video/mp4. Empty disables video uploads. |
+| `media.webp_quality` | `u8` | `82` | No | ffmpeg | Restart required; Image quality, 0–100. Higher values produce larger files. |
+| `media.vp9_crf` | `u8` | `32` | No | ffmpeg | Restart required; VP9 constant quality, 0–63. Lower values produce larger files. |
+| `media.vp9_deadline` | `String` | `"good"` | No | ffmpeg | Restart required; Best prioritizes quality; good balances speed; realtime prioritizes speed. |
+| `tor.enabled` | `bool` | `false` | No | server, backup, tor, cli | Restart required; Start the embedded Arti onion service on restart. |
+| `tor.tor_only` | `bool` | `false` | No | tor, cli | 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 | Private-key storage relocation needs a filesystem migration; changing it alone can lose onion identity. |
+| `tor.onion_service_name` | `String` | `"microblog"` | No | tor | 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 | Restart required; Must remain blank by validation. The running Arti service supplies the displayed address. |
+| `tor.bootstrap_timeout_secs` | `u64` | `120` | No | tor | Restart required; Seconds allowed for Arti bootstrap. |
+| `tor.max_concurrent_streams` | `usize` | `512` | No | tor | Restart required; Positive bound used for Arti streams per circuit and global forwarding concurrency; must fit the runtime limit. |
+| `tor.include_tor_keys_in_backups_by_default` | `bool` | `false` | No | config validation forbids true; manual backups use explicit consent | Must remain false by validation; manual private-key inclusion requires explicit consent. |
+| `moderation.posts_per_minute` | `i64` | `5` | No | server | Restart required; Posts per account per minute; values of 0 or less block posting. |
+| `moderation.replies_per_minute` | `i64` | `10` | No | server | Restart required; Replies per account per minute; values of 0 or less block replies. |
+| `moderation.reposts_per_minute` | `i64` | `10` | No | server | 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 | 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 | 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 | 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 | Restart required; Prompt for an administrator at startup only when no administrator exists. |
+| `backup.enabled` | `bool` | `true` | Yes | server, backup, tor, cli | Live / next scheduler check; Allow manual backups and scheduled backups. |
+| `backup.backup_dir` | `String` | `"backups"` | Yes | runtime paths + backup scheduler/restore | Backup storage relocation needs a filesystem migration; startup paths are immutable. |
+| `backup.automatic_enabled` | `bool` | `false` | Yes | server, backup | Live / next scheduler check; Run automatic backups using the configured interval. |
+| `backup.automatic_interval_minutes` | `u64` | `1440` | Yes | server, backup | Live / next scheduler check; Minutes between backups; minimum 1. Scheduler checks once per minute. |
+| `backup.retention_keep_last` | `usize` | `10` | Yes | server, backup | 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 | Live / next scheduler check; Days, 0–3650. Set 0 to disable age-based pruning. |
+| `backup.automatic_include_tor_keys` | `bool` | `false` | Yes | server, backup | 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 |
+
+## What is not a site setting?
+
+Parser safety ceilings, schema versions, search result caps, database worker sizes, fixed cleanup intervals, and archive format rules are implementation limits. They cannot be tuned through this settings file. There is no SMTP service, federation protocol, external database connection string, or built-in TLS certificate configuration.
+
+Runtime directory names and database/private-key relocation require an offline filesystem procedure. Account preferences, moderation records, and import/export history belong to their dedicated account or administration screens. Demo credentials are confined to the explicit demo-seed operation.
diff --git a/docs/demo-preview.md b/docs/demo-preview.md
index 9d3d73e..bbd5aba 100644
--- a/docs/demo-preview.md
+++ b/docs/demo-preview.md
@@ -1,21 +1,23 @@
# Local demo and screenshots
+[All guides](README.md) · [Build from source](development.md#build-and-run) · [Screenshots](../README.md#see-the-app)
+
The README screenshots show a local demo with fictional accounts, generated illustrations, and short sample videos. No real account data or internet media is used.
The screenshot refresh for v1.0.0 was captured on September 29, 2026. Desktop images use a 1440 × 1200 browser viewport; the mobile image uses a 390 × 844 viewport.
## What you need
-- Rust 1.91 or newer to build the app.
+- Git and rustup, using the repository’s pinned Rust 1.99.1 compiler. Start from a current `dev` checkout; see [Build from source](development.md#build-and-run).
- `ffmpeg` with the `drawtext` filter and the `libvpx-vp9` encoder to generate the sample videos. These are needed for this demo even though `ffmpeg` is optional for normal RustPost use. Check your build with `ffmpeg -filters` and `ffmpeg -encoders`.
-## Build
+## 1. Build
```sh
-cargo build --workspace --all-features
+cargo build --locked --workspace --all-features --bin rustpost-cli
```
-## Seed
+## 2. Add sample content
```sh
./target/debug/rustpost-cli --data-dir target/debug/rustpost-demo-screenshots seed-demo
@@ -23,15 +25,17 @@ cargo build --workspace --all-features
The `seed-demo` command only accepts an explicit demo directory under `target/debug` whose name contains `rustpost-demo`. It refuses to seed a database that already has users. Use a new demo folder for another run instead of clearing existing data.
-## Run
+## 3. Run the demo
```sh
./target/debug/rustpost-cli --data-dir target/debug/rustpost-demo-screenshots serve
```
-Open [http://127.0.0.1:8098](http://127.0.0.1:8098).
+Open [http://127.0.0.1:8098](http://127.0.0.1:8098). The demo uses port **8098**, not the usual 8080. Keep the terminal open and press Ctrl+C when finished.
+
+On Windows, use `.\target\debug\rustpost-cli.exe` and the same explicit demo-directory argument. The seed command currently checks for the literal `target/debug/` path segment, so keep forward slashes in that argument.
-## Demo Accounts
+## Sign in to a demo account
All demo accounts use the same local-only password:
diff --git a/docs/deployment.md b/docs/deployment.md
new file mode 100644
index 0000000..f079101
--- /dev/null
+++ b/docs/deployment.md
@@ -0,0 +1,71 @@
+# Make your site public
+
+The [quick start](getting-started.md) runs RustPost for your own computer. To invite other people, run it on a computer or server that stays on and give visitors an address they can reach.
+
+The usual public setup is **browser → HTTPS reverse proxy → RustPost**. A reverse proxy accepts encrypted web traffic and forwards it to the app. RustPost itself serves HTTP.
+
+[All guides](README.md) · [Docker](docker.md) · [Settings](operator-guide.md#configuration) · [Backups](operator-guide.md#backup-and-restore)
+
+## Before inviting people
+
+1. Complete local setup and create the administrator.
+2. Decide whether anyone can register. Change `accounts.registration_enabled` if you want signups closed.
+3. Configure your domain, HTTPS certificate, and reverse proxy.
+4. Keep RustPost's HTTP port accessible only to the proxy. Use firewall rules and suitable listener settings for your deployment.
+5. Make a backup and arrange an off-host copy.
+6. Verify login, posting, media, and restart persistence through the public address.
+
+Use a dedicated unprivileged OS account and a private data directory. For an unattended process, configure your service manager to start the executable with an absolute `--data-dir` and `--console off`, and to send a graceful stop signal. Allow backup work to finish before forcing termination. There is no bundled service-manager installation or updater daemon.
+
+## HTTPS and proxy settings
+
+For a proxy running directly on the same host, edit the existing settings to include:
+
+```toml
+[server]
+host = "127.0.0.1"
+port = 8080
+public_url = "https://social.example.com"
+cookie_secure = true
+trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"]
+```
+
+Replace the example domain with your real HTTPS address; keep the rest of the generated settings file. Validate and restart RustPost. `public_url` is display information, not a substitute for DNS, TLS, host validation, or proxy configuration; generated site links remain relative.
+
+The proxy must preserve the public **Host** header, supply a correct **X-Forwarded-Proto**, and build a trustworthy **X-Forwarded-For** chain. Strip or overwrite untrusted client-supplied forwarding headers at your public edge. Trust only the actual proxy addresses; avoid broad CIDRs that include visitors.
+
+In v1.1.0, RustPost accepts forwarding metadata only from a direct peer listed in `trusted_proxy_cidrs`. It walks X-Forwarded-For from the right through trusted addresses to find the client IP. Untrusted peers use their direct address. Malformed or duplicate forwarding metadata can be rejected. This differs from older documentation describing the setting as unused.
+
+The request guard checks browser origin information and secure-cookie behavior against the request host and scheme. A wrong Host or scheme can cause rejected forms or login failures. Authenticated forms also require CSRF tokens. Test the whole login/posting flow through HTTPS, not just the home page.
+
+### Containers and proxies on other hosts
+
+The Docker image listens on `0.0.0.0` **inside** the container on first boot, but the quick-start port mapping publishes only to the host's `127.0.0.1`. A proxy in a different container or on another host needs an intentionally configured private network path.
+
+Set `trusted_proxy_cidrs` to the actual proxy peer address seen by RustPost. Docker networking may mean that address is not loopback. Keep access to the app port restricted even when its listener is `0.0.0.0`; that setting alone does not secure it. See [Docker storage and settings](docker.md).
+
+## Tor alongside HTTPS
+
+You can add an [onion address](operator-guide.md#tor--arti) while keeping HTTPS available. Version 1.1.0 distinguishes Arti-forwarded requests from ordinary proxy traffic, so onion clients cannot impersonate a trusted loopback proxy. Onion HTTP does not inherit the clearnet secure-cookie setting.
+
+Test ordinary HTTPS and onion login separately. Publishing an onion address is not proof of external reachability or an anonymity guarantee.
+
+## Health and logs
+
+Use the [terminal console](terminal-console.md), **Admin → Health**, and **Media jobs** to inspect the running app. `/`, `/home`, `/login`, and `/register` are public routes useful for HTTP checks. RustPost does not provide `/healthz` or `/readyz`; the health page requires an admin session.
+
+Operational events are retained in a bounded in-memory log and `logs/events.log`. These omit arbitrary request/secret fields and are best-effort diagnostics, not a durable audit journal. Retain the data directory and back up the whole site using the app's backup format rather than copying a live SQLite file alone.
+
+## Security and dependency review
+
+RustPost hashes passwords with Argon2id, uses HttpOnly/SameSite=Lax session cookies, validates authenticated form tokens, escapes rendered user content, and validates uploaded content rather than trusting filenames. SVG is not an allowed default upload type. These controls still depend on correct deployment and maintained dependencies.
+
+Dependency review is separate from successful compilation and tests. The v1.1.0 changelog records open upstream **RUSTSEC-2023-0071** and **RUSTSEC-2026-0387** findings. Do not treat an old advisory list as a fresh audit: inspect the latest [Dependency Audit workflow](https://github.com/csd113/RustPost/actions/workflows/audit.yml) report and run `cargo audit --file Cargo.lock` when evaluating a source build or release. The workflow is report-only; a successful workflow status can still include vulnerability findings.
+
+For historical context, v1.0.0 recorded the transitive `rsa 0.9.10` / RUSTSEC-2023-0071 exception and maintenance warnings for `bincode 2.0.1` and `paste 1.0.15`. Those notes are evidence about that release, not a claim that the current dependency graph is clean or identical. Keep the [changelog](../CHANGELOG.md) and the current audit report with your deployment review.
+
+## Practical limits
+
+RustPost serves one independent community and does not federate. Search uses SQLite FTS5 with basic user matching and fixed result limits. Reporting and moderation workflows are limited, and broader accessibility review is still outstanding. Media conversion runs during uploads, so large files and slow hardware can delay requests.
+
+After updates or configuration changes, recheck your public login, posting, media, backup, and restart flows. See [Software updates](software-updates.md) for manual installation and rollback considerations.
diff --git a/docs/development.md b/docs/development.md
new file mode 100644
index 0000000..1e0159c
--- /dev/null
+++ b/docs/development.md
@@ -0,0 +1,103 @@
+# Build and develop RustPost
+
+You only need the Rust toolchain if you want to build or change the app. To run a ready-made copy, use [Getting started](getting-started.md) or [Docker](docker.md).
+
+[All guides](README.md) · [Source](https://github.com/csd113/RustPost/tree/dev) · [Changelog](../CHANGELOG.md)
+
+## Build and run
+
+Install Git and [rustup](https://rustup.rs/), plus the native compiler/linker tools for your operating system. The repository pins **Rust 1.99.1** in `rust-toolchain.toml`; `Cargo.toml` also declares 1.99.1 as the minimum supported version. Rustup selects the pin inside the repository without changing your global default.
+
+```sh
+git clone --branch dev https://github.com/csd113/RustPost.git
+cd RustPost
+cargo build --locked --release --bin rustpost-cli
+./target/release/rustpost-cli --data-dir ./rustpost-data serve
+```
+
+On Windows, run `.\target\release\rustpost-cli.exe --data-dir .\rustpost-data serve`. Follow the terminal's administrator prompts and visit **[http://127.0.0.1:8080](http://127.0.0.1:8080)**. The [first-run guide](getting-started.md) applies after the build.
+
+`dev` is the current development branch. It can contain changes that are not in a published release, even when `--version` prints the same package version as a release (currently 1.1.0). Record `git rev-parse HEAD` with a build. To reproduce a release, check out that release's tag instead of assuming dev has the same content.
+
+RustPost uses SQLite and server-rendered HTML, CSS, and JavaScript. There is no separate database server or frontend build step. `ffmpeg` is optional for ordinary operation and required for the [demo's sample-video generation](demo-preview.md).
+
+## Find the code
+
+| Area | Main files |
+| --- | --- |
+| CLI and runtime paths | `src/cli.rs`, `src/runtime.rs` |
+| HTTP routes | `src/server.rs`, `src/server/` |
+| Settings and editor fields | `src/config.rs`, `src/config/admin_fields.rs` |
+| Database and migrations | `src/db/` |
+| Posts, follows, and notifications | `src/social/` |
+| Accounts and portability | `src/account.rs`, `src/portability/` |
+| Media | `src/media/`, `src/ffmpeg.rs` |
+| Backups | `src/backup/` |
+| Tor and release discovery | `src/tor.rs`, `src/updates.rs` |
+| Terminal and operational logs | `src/console/`, `src/terminal/`, `src/logging.rs` |
+| Web rendering and assets | `src/render/`, `assets/` |
+
+Use a disposable data directory for development. Never point a demo or smoke test at a real community's data. For a populated local site, follow [Local demo](demo-preview.md).
+
+## Checks
+
+Run these from the repository root using the pinned toolchain:
+
+```sh
+cargo fmt --all --check
+cargo clippy --locked --workspace --all-targets --all-features -- -D warnings -D clippy::all -D clippy::pedantic -D clippy::nursery -D clippy::cargo
+cargo test --locked --workspace --all-features
+cargo build --locked --release --bin rustpost-cli
+```
+
+Build API documentation with warnings treated as errors on Linux/macOS:
+
+```sh
+RUSTDOCFLAGS='-D warnings' cargo doc --locked --workspace --all-features --no-deps
+```
+
+PowerShell equivalent:
+
+```powershell
+$env:RUSTDOCFLAGS = '-D warnings'
+cargo doc --locked --workspace --all-features --no-deps
+Remove-Item Env:RUSTDOCFLAGS
+```
+
+Review `cargo audit --file Cargo.lock` separately (requires `cargo-audit`). Advisory review is not evidence of functional correctness, and passing CI is not a clean dependency-audit claim. See [dependency notes](deployment.md#security-and-dependency-review).
+
+Current CI runs formatting, strict Clippy, and rustdoc, then native CLI builds/tests on Linux x86_64, Linux ARM64, Intel Mac, Apple silicon Mac, and Windows x86_64. Current tests include terminal/CLI and CSRF route regressions; older compiler-upgrade records refer to application and Playwright suites that are no longer present. Use the current workflow and `Cargo.toml`, not historical test counts, to identify the active checks.
+
+### Documentation checks
+
+For documentation changes, run `git diff --check`, verify relative file links and heading anchors, and compare commands/settings with the installed CLI or source. Keep fenced TOML excerpts clearly marked when they are not complete configuration files. Preserve existing image paths and license/provenance information.
+
+For setup changes, use a fresh disposable data directory to check `init`, `check`, administrator creation, local HTTP, shutdown, and restart persistence. Test restore only against disposable data with the server stopped. Do not claim other operating systems or external Tor access were tested from a local-only check.
+
+## Release packaging
+
+**Older v1.0.0** uses `rustpost-linux-x86_64.tar.gz`, `rustpost-linux-aarch64.tar.gz`, `rustpost-macos-aarch64.tar.gz` (Apple silicon), and `rustpost-windows-x86_64.zip`, each with a matching `.sha256` file. These archives contain a `rustpost` folder. Those existing artifacts are not renamed by changing the workflow.
+
+The **v1.1.0 release workflow** prepares these five ZIP patterns for a `vVERSION` tag (`VERSION` is `1.1.0` for this release):
+
+| Platform | Archive pattern |
+| --- | --- |
+| Linux x86_64 | `rustpost-cli-vVERSION-linux-x86_64.zip` |
+| Linux ARM64 | `rustpost-cli-vVERSION-linux-arm64.zip` |
+| macOS Intel | `rustpost-cli-vVERSION-macos-intel.zip` |
+| macOS Apple silicon | `rustpost-cli-vVERSION-macos-apple-silicon.zip` |
+| Windows x86_64 | `rustpost-cli-vVERSION-windows-x86_64.zip` |
+
+Each ZIP contains a matching version/platform folder with the executable, README, LICENSE, and CHANGELOG. It does not bundle the full `docs/` directory or screenshots; use the repository's [online guides](https://github.com/csd113/RustPost/tree/dev/docs) when reading an extracted package. The workflow writes a shared `SHA256SUMS.txt` and verifies every expected archive.
+
+The release workflow checks that the tag matches the package version, validates code quality/rustdoc, verifies the native executable version and Mac architecture, and prepares a **draft** GitHub release. It refuses to modify an already-published release. Publishing a draft is a separate maintainer action.
+
+The container workflow builds Linux amd64/arm64 images from a source tag and publishes version tags, plus `latest` for stable versions. The image uses an unprivileged UID/GID 10001, persistent `/data`, and bundled `ffmpeg`. Its Dockerfile starts with `rust:1.98.1-bookworm` and installs the exact 1.99.1 compiler; that base-image tag is not the final build compiler version.
+
+## Change the compiler or dependencies
+
+Update `rust-toolchain.toml`, `Cargo.toml`'s supported-version requirement when appropriate, workflow pins, and Dockerfile compiler selection together. Resolve dependencies deliberately, retain `Cargo.lock`, and pass the checks on the intended platforms before using a new build for release. Do not rewrite published migration history.
+
+Arti/Tor dependencies are currently pinned to 0.48.0, with `rusqlite` 0.40.2 to keep SQLite linkage compatible. `cargo tree --locked -i libsqlite3-sys` should show one resolved SQLite sys crate. Re-evaluate upstream advisories when updating the Tor stack.
+
+For provenance of the earlier compiler migration, read [Compiler upgrade history](rust-1.99-upgrade.md). That historical report is not the current build checklist.
diff --git a/docs/docker.md b/docs/docker.md
index 1ef34f6..0176163 100644
--- a/docs/docker.md
+++ b/docs/docker.md
@@ -1,8 +1,48 @@
-# Running RustPost with Docker
+# Run RustPost with Docker
-Docker runs RustPost in a container: a packaged copy of the app and the tools it needs. The released image is `ghcr.io/csd113/rustpost:1.0.0`, with Linux amd64 and arm64 support and `ffmpeg` included for media conversion.
+Docker runs a packaged copy of RustPost with `ffmpeg` included for media conversion. Use this if you already have Docker installed and running, or prefer containers to a downloaded executable.
-Start with the [three-step setup in the README](../README.md#run-the-container). This guide covers the next steps. The commands assume your container is named `rustpost` and uses the `rustpost-data` volume from that setup.
+[All guides](README.md) · [Download an app instead](getting-started.md) · [Public deployment](deployment.md)
+
+For v1.1.0, use `ghcr.io/csd113/rustpost:1.1.0` once that image is published, with Linux amd64 and arm64 support. Docker Desktop can run the appropriate Linux image on compatible macOS and Windows computers. If v1.1.0 is not available yet, the older published `1.0.0` image predates features described here; see [version scope](README.md#which-version-do-these-guides-describe).
+
+## Start RustPost
+
+### 1. Start the container
+
+Run these commands in a terminal (the multiline examples use a POSIX shell; in PowerShell put the `docker run` arguments on one line):
+
+```sh
+docker volume create rustpost-data
+docker run -d --name rustpost \
+ -p 127.0.0.1:8080:8080 \
+ -v rustpost-data:/data \
+ --restart unless-stopped \
+ ghcr.io/csd113/rustpost:1.1.0
+```
+
+Docker downloads the image if necessary. The volume is the storage area for your site; retain it when replacing the container. The port mapping makes the site reachable only from the Docker host.
+
+### 2. Create your administrator
+
+```sh
+docker exec -it rustpost rustpost-cli --data-dir /data create-admin-interactive
+```
+
+Choose a username and a unique password of at least 10 characters. Hidden password prompts show no characters as you type. There is no default password.
+
+### 3. Open your site
+
+Visit **[http://127.0.0.1:8080](http://127.0.0.1:8080)** on the Docker host, log in, and finish any profile setup shown. Try writing a post. Use **Admin** to manage the site.
+
+```sh
+docker logs rustpost # Read startup messages or errors
+docker stop rustpost # Stop the site
+docker start rustpost # Start it again
+docker restart rustpost # Reload startup settings
+```
+
+For a public site, follow [Deployment](deployment.md) after finishing setup. The rest of this guide assumes the container is named `rustpost` and uses the `rustpost-data` volume above.
## Where your data lives
@@ -22,7 +62,7 @@ sudo chown 10001:10001 /srv/rustpost
sudo chmod 700 /srv/rustpost
```
-In the README's `docker run` command, replace `-v rustpost-data:/data` with `-v /srv/rustpost:/data`. Use a named volume if you do not need a specific host folder.
+In the `docker run` command above, replace `-v rustpost-data:/data` with `-v /srv/rustpost:/data`. Use a named volume if you do not need a specific host folder.
For a new data directory, the container creates `settings.toml` and sets `server.host` to `0.0.0.0` so Docker can forward traffic. Existing settings are preserved. If you bring an existing RustPost data directory, edit its `[server]` section to use `host = "0.0.0.0"` before starting it in Docker.
@@ -38,9 +78,9 @@ docker exec rustpost rustpost-cli --data-dir /data check
docker restart rustpost
```
-Only restart if `check` succeeds. If it reports an error, fix the file and copy it back first. Keep the file private: it describes your deployment. The [operator guide](operator-guide.md#configuration) explains the available settings.
+If Docker copies the file as root, restore ownership with `docker exec --user 0 rustpost chown 10001:10001 /data/settings.toml` so the web editor can continue saving it. Only restart if `check` succeeds. If it reports an error, fix the file and copy it back first. Keep the file private: it describes your deployment. The [operator guide](operator-guide.md#configuration) explains the available settings.
-The quick start publishes port 8080 on `127.0.0.1`, so only the host computer can connect directly. For a public site, configure HTTPS through a reverse proxy and the corresponding settings described in the [README](../README.md#making-your-site-public).
+The quick start publishes port 8080 on `127.0.0.1`, so only the host computer can connect directly. For a public site, configure HTTPS through a reverse proxy and the corresponding settings described in the [deployment guide](deployment.md).
## Backing up and restoring
@@ -58,9 +98,7 @@ docker cp rustpost:/data/backups/ARCHIVE_NAME.tar ./ARCHIVE_NAME.tar
Tor private keys are excluded by default. Include them only when needed, and protect any archive containing them.
-For a browser restore, use **Admin → Backups**, follow the restore confirmation, then run `docker restart rustpost` so the app loads the restored database and settings.
-
-For a terminal restore, stop the server and use a temporary container with the same volume. First copy the backup into the volume while the original container still exists:
+Restore with the server stopped. Version 1.1.0 disables browser restore; use the offline procedure below, which also avoids replacing a live database on older releases. First copy the backup into the volume while the original container is running:
```sh
docker cp ./ARCHIVE_NAME.tar rustpost:/data/backups/restore-input.tar
@@ -69,7 +107,7 @@ docker exec --user 0 rustpost chmod 600 /data/backups/restore-input.tar
docker stop rustpost
docker run --rm \
-v rustpost-data:/data \
- ghcr.io/csd113/rustpost:1.0.0 \
+ ghcr.io/csd113/rustpost:1.1.0 \
restore /data/backups/restore-input.tar
```
@@ -77,25 +115,25 @@ The ownership commands let the app's non-root user read the copied archive while
## Replacing or upgrading the container
-Create and download a backup first, and read the target release notes. The example below recreates the container using v1.0.0; substitute the published version you intend to install when upgrading.
+Create and download a backup first, and read the target release notes. The example below recreates the container using v1.1.0; substitute the published version you intend to install when upgrading.
Download the image before stopping the app:
```sh
-docker pull ghcr.io/csd113/rustpost:1.0.0
+docker pull ghcr.io/csd113/rustpost:1.1.0
docker stop rustpost
docker rm rustpost
docker run -d --name rustpost \
-p 127.0.0.1:8080:8080 \
-v rustpost-data:/data \
--restart unless-stopped \
- ghcr.io/csd113/rustpost:1.0.0
+ ghcr.io/csd113/rustpost:1.1.0
docker logs rustpost
```
Reuse the same volume or bind mount and any custom port or network settings from your original container. You do not need to create the administrator account again. Open the site and confirm your accounts, posts, and media are still there.
-The `latest` tag currently points to v1.0.0. A version tag makes the release you are running explicit; restarting an existing container does not download a newer image.
+The `latest` tag can move as releases are published. A version tag makes the release you intend to run explicit; restarting an existing container does not download a newer image.
## Troubleshooting
@@ -104,5 +142,9 @@ The `latest` tag currently points to v1.0.0. A version tag makes the release you
| The browser cannot reach the site | Run `docker ps -a` and `docker logs rustpost`. Confirm port 8080 is available and you are browsing on the Docker host. |
| An existing data directory will not serve through Docker | Check that `server.host` is `0.0.0.0` inside the container's settings. |
| 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. |
+| You cannot log in as administrator | Run the interactive administrator creation command above 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
+
+Version 1.1.0 provides a manual GitHub release check in **Admin → Software updates**. It does not replace containers. Use the [update guide](software-updates.md) and retain the persistent `/data` volume.
diff --git a/docs/getting-started.md b/docs/getting-started.md
new file mode 100644
index 0000000..f82ddc0
--- /dev/null
+++ b/docs/getting-started.md
@@ -0,0 +1,112 @@
+# Getting started
+
+RustPost runs a small social website from one application. This guide takes you from a download to your first post on your own computer. You can make the site public later.
+
+[All guides](README.md) · [Docker instead](docker.md) · [Build from source](development.md#build-and-run)
+
+## Download and extract
+
+1. Open the [latest release](https://github.com/csd113/RustPost/releases/latest) and expand **Assets**.
+2. Choose the application archive for your operating system and processor from the [download table](../README.md#1-download-the-app). A recent Mac's **About This Mac** window says whether it uses an Apple chip or Intel. On Linux, `uname -m` commonly prints `x86_64` or `aarch64`.
+3. Extract the archive into a folder you can write to. Open the extracted folder until you see `rustpost-cli` (Linux/macOS) or `rustpost-cli.exe` (Windows).
+4. Open a terminal there. On Windows, use **Open in Terminal** from that folder and select PowerShell. In a terminal, `cd` changes folders; quote paths that contain spaces.
+
+The v1.1.0 ZIPs contain a folder named for their version and platform, such as `rustpost-cli-v1.1.0-macos-apple-silicon`. Use them when v1.1.0 is published; older v1.0.0 archives contain a `rustpost` folder and use different archive/checksum names. Always work beside the executable. See [Release packaging](development.md#release-packaging) for the older names.
+
+## Start the app
+
+Linux or macOS:
+
+```sh
+chmod +x rustpost-cli
+./rustpost-cli --data-dir ./rustpost-data serve
+```
+
+Windows PowerShell:
+
+```powershell
+.\rustpost-cli.exe --data-dir .\rustpost-data serve
+```
+
+The first start creates a `rustpost-data` folder beside the app when you run these commands from the executable's folder. It contains settings, accounts, posts, and uploads. Keep it private and retain it when updating.
+
+## Create your administrator
+
+In an interactive terminal, first startup asks for an administrator when none exists. Enter your username, an optional display name if offered, and a unique password. The default minimum password length is 10 characters. Hidden prompts show no password characters; enter it again when asked to confirm.
+
+There is no default administrator password. If the process starts without prompting, use a second terminal in the same folder:
+
+```sh
+./rustpost-cli --data-dir ./rustpost-data create-admin-interactive
+```
+
+On Windows, use `.\rustpost-cli.exe` in place of `./rustpost-cli`. This command is also how you create the first admin for a background process. Do not use a different data directory: that would create an account for a different site.
+
+## Open and use your site
+
+Wait for startup to finish, then visit **[http://127.0.0.1:8080](http://127.0.0.1:8080)** in a browser on the computer running RustPost.
+
+1. Log in with the administrator account you just created.
+2. Complete any profile setup shown after login.
+3. Write a short post in the Home Feed. Open the post to reply or view its conversation.
+4. Open **Settings** for your profile or **Admin** to manage your site.
+
+`127.0.0.1` means this computer. It is not an address other people can use to visit your server. Follow [Make your site public](deployment.md) when you are ready to share it.
+
+## Stop and start again
+
+Keep the app running while you browse. To stop it, press **Ctrl+C** in its terminal and wait for shutdown to finish. Start it again with the same command and data directory. Your accounts and content remain saved.
+
+Version 1.1.0 has a [live console](terminal-console.md): press **1–6** to switch views, **?** for help, or **Q** to confirm shutdown. Published older builds may have a different terminal display.
+
+## Optional media conversion
+
+You can post allowed images and videos without installing extra tools. Installing `ffmpeg` enables image/video conversion and video thumbnails. If conversion is unavailable or fails, RustPost can retain an allowed upload in its original format; browser playback then depends on that format.
+
+**Admin → Health** reports media-tool availability. The [Docker image](docker.md) includes `ffmpeg`; other installations can provide it on the app's executable search path or set `media.ffmpeg_path` in `settings.toml`. See [Media](operator-guide.md#media-and-ffmpeg) for formats and limits.
+
+## Verify a download
+
+A checksum helps detect a damaged or mismatched download. Get `SHA256SUMS.txt` from the same v1.1.0 GitHub release as the ZIP and check it before extracting. These examples assume the archive and manifest are in your terminal's current folder; substitute your platform's filename from the [download table](../README.md#1-download-the-app).
+
+Linux (one downloaded ZIP):
+
+```sh
+sha256sum rustpost-cli-v1.1.0-linux-x86_64.zip
+cat SHA256SUMS.txt
+```
+
+macOS (Apple silicon; Intel uses `macos-intel`):
+
+```sh
+shasum -a 256 rustpost-cli-v1.1.0-macos-apple-silicon.zip
+cat SHA256SUMS.txt
+```
+
+Windows PowerShell:
+
+```powershell
+Get-FileHash .\rustpost-cli-v1.1.0-windows-x86_64.zip -Algorithm SHA256
+Get-Content .\SHA256SUMS.txt
+```
+
+Compare the complete hash with the manifest line for your exact ZIP filename, ignoring letter case. Do not run an archive whose hash differs; download it and the manifest again from the release page.
+
+With all five ZIPs downloaded, `sha256sum -c SHA256SUMS.txt` (Linux) or `shasum -a 256 -c SHA256SUMS.txt` (macOS) checks the full set and should report `OK` for each file. With only one ZIP, these full-set commands report missing files for the others. Older v1.0.0 downloads use individual `.sha256` files instead; follow the checksum names on that release.
+
+## Troubleshooting
+
+| What happened? | What to do |
+| --- | --- |
+| The browser says it cannot connect | Check that the app is still running, startup has finished, and the URL is `http://127.0.0.1:8080`. Use the same computer. Read the terminal error if the app exited. |
+| “Address already in use” | Another process is using port 8080. Stop the other RustPost process, or edit `port` under `[server]` in `rustpost-data/settings.toml`, restart, and use the new port in the URL. |
+| “No such file” or command not found | Open a terminal in the folder containing the executable. Include `./` on Linux/macOS or `.\` in PowerShell. |
+| “Permission denied” on Linux/macOS | Run `chmod +x rustpost-cli`; also check that the app can write to its data folder. |
+| The OS blocks the download | Confirm its source and checksum. Use the OS's security review for this specific app if you trust it; do not disable system-wide security. |
+| Wrong architecture or missing system library | Recheck the download's operating system and processor. Linux releases use GNU/Linux targets; a minimal or older distribution may need a compatible runtime. Docker is an alternative. |
+| My site looks empty after restarting | Check the `--data-dir` path and current folder. A different path opens a different site. Stop and restart using the original folder. |
+| I forgot the administrator password | Use the [password reset command](operator-guide.md#administrator-accounts) against the original data directory. |
+| Settings changed but the site did not | Most settings need a restart. Follow the [configuration guide](operator-guide.md#configuration). |
+| Uploads fail or videos do not play | Check upload limits and **Admin → Health / Media jobs**. The original format may not be playable in every browser. |
+
+For reproducible bugs, use the [issue tracker](https://github.com/csd113/RustPost/issues). Include your OS, downloaded release or source commit, command (without secrets), and the error. Do not attach a private database, passwords, account archives, or Tor keys.
diff --git a/docs/operator-guide.md b/docs/operator-guide.md
index f405a38..a27f79d 100644
--- a/docs/operator-guide.md
+++ b/docs/operator-guide.md
@@ -1,377 +1,204 @@
-# RustPost operator guide
+# Looking after your RustPost site
-This guide covers detailed configuration, administration, backups, Tor, and the v1.0.0 release checks. For installation and everyday use, start with the [README](../README.md).
+Start RustPost, sign in as your administrator, and open **Admin**. You can manage accounts, change settings, check media processing, and create backups there. For your first installation, use [Getting started](getting-started.md).
-[Commands](#cli-reference) · [Configuration](#configuration) · [Security](#security-model) · [Tor](#tor--arti) · [Media](#media-and-ffmpeg) · [Backups](#backup-and-restore) · [Release checks](#release-verification)
+[All guides](README.md) · [Settings](#configuration) · [Backups](#backup-and-restore) · [Commands](#cli-reference) · [Public deployment](deployment.md) · [Updates](software-updates.md)
-## CLI reference
-
-For an installed app, add `--data-dir /path/to/your/site` before the subcommand, using the same directory every time. In Docker, use `docker exec rustpost rustpost-cli --data-dir /data `; add `-it` to `docker exec` for interactive prompts.
-
-```sh
-rustpost-cli init # Initialize data directory
-rustpost-cli check # Validate config, data directory, and DB schema status
-rustpost-cli create-admin # Create an admin account
-rustpost-cli create-admin-interactive # Create an admin with hidden password prompts
-rustpost-cli reset-admin-password # Reset an admin's password
-rustpost-cli --data-dir target/debug/rustpost-demo seed-demo # Create local demo data
-rustpost-cli serve # Start the HTTP server (default)
-rustpost-cli backup # Create a backup archive
-rustpost-cli backup --include-tor-keys # Backup including Tor private keys
-rustpost-cli restore # Restore from a backup
-rustpost-cli restore --include-tor-keys # Restore including Tor keys
-```
+This guide describes **v1.1.0**. Older v1.0.0 differs in some operational behavior; in particular, v1.1.0 requires restoring with the server stopped.
-Prefer `create-admin-interactive` for local setup: it hides the password while you type. Passwords passed as arguments to `create-admin` or `reset-admin-password` can be visible to other local processes.
+## Everyday administration
-### Runtime storage
+| Task | Where to go |
+| --- | --- |
+| Rename the site or close registration | **Admin → Deep server settings**; review, save, then restart when indicated |
+| Manage accounts and sessions | **Admin → Users** |
+| Publish an announcement or pause publishing | **Admin** overview |
+| Check database, Tor, or media readiness | **Admin → Health** |
+| Review conversion results | **Admin → Media jobs** |
+| Create, download, or schedule backups | **Admin → Backups** |
+| Check for a newer release | **Admin → Software updates** |
-An explicit `--data-dir` keeps settings, the database at `db/rustpost.sqlite3`, uploads, assets, backups, logs, temporary upload files, and Tor state together. Without it, the default `rustpost-data` folder is beside the executable. Runtime paths do not depend on the current working directory. On Unix the app restricts the data directory to mode `0700`.
-
-Older data directories with `app.sqlite3` at their root are migrated to `db/rustpost.sqlite3` on startup. If both files exist, the app stops with a conflict error and preserves both.
-
----
+Maintenance mode pauses publishing for ordinary users while keeping the site readable. It does not stop the process or permit a live restore. See [community controls](account-and-instance-features.md#community-announcements-and-maintenance).
## Configuration
-`settings.toml` is generated on first run with conservative, safe defaults. Edit it to match your deployment.
+RustPost creates `settings.toml` on first run. It is a text file with sections such as `[site]` and `[accounts]`. You can edit most settings from **Deep server settings**: change values, review the before/after summary, then confirm or discard. The page shows saved values and differences from the running configuration.
-### Site name
+Most changes take effect **after restarting RustPost**. The NSFW blur switch applies immediately when saved through the admin editor. Backup policy is read for operations and by the scheduler on its next check (about once a minute). Announcement and maintenance changes are live database settings.
-The visible site name is configured in `settings.toml`:
+For a file edit, change existing entries rather than adding a second copy of a section:
```toml
[site]
-name = "RustPost"
-```
-
-Changing `site.name` updates the rendered browser title, header brand, footer, and user-facing site copy. The executable remains `rustpost-cli`; package names, cookie names, and data paths remain `rustpost` for compatibility.
-
-### Account creation
+name = "Our community"
-```toml
[accounts]
-registration_enabled = true
-registration_captcha_enabled = false
+registration_enabled = false
```
-`registration_captcha_enabled` adds a single-use CAPTCHA challenge to registration only. Login is unchanged.
-
-### Account lifecycle
-
-```toml
-[accounts]
-deletion_grace_period_days = 30
-max_archive_upload_bytes = 314572800
-max_archive_expanded_bytes = 1073741824
-max_archive_entries = 10000
-```
-
-Deleting an account stores a deletion deadline instead of removing data immediately; the owner can cancel until the deadline, and finalization runs from the maintenance scheduler (including after a restart). The default is 30 days; `0` deletes immediately after password confirmation.
-
-The three archive ceilings are independent and documented in [account-and-instance-features.md](account-and-instance-features.md): the compressed upload/export size, the total decompressed media size accepted during import, and the entry count. The `/settings/import` body limit is derived from the compressed ceiling plus a small multipart allowance, so archive imports never depend on the global media body limit, and an oversized upload is rejected with a clear `413` page before any account state changes.
-
-Announcements and maintenance mode are managed from the admin dashboard and stored in the database, so they survive restarts and are included in backups. See [account-and-instance-features.md](account-and-instance-features.md) for the full behavior and policy details.
+This is an excerpt, not a complete replacement file. Keep the other generated sections. Validate the file before restarting:
-### Post editing
-
-```toml
-[posts]
-post_edit_window_seconds = 15
+```sh
+./rustpost-cli --data-dir ./rustpost-data check
```
-Users can edit their own post text only during this short server-enforced window. The default is 15 seconds; set it to `0` to disable post editing.
+If validation fails, fix the reported value before restarting. `check` reads settings and inspects schema compatibility; it does not initialize or repair the database. Use `init` first for a new directory.
-### Clearnet only (default)
+For Docker, see [Editing settings](docker.md#editing-settings). For every key, default, and special case, see the [configuration reference](admin-configuration-audit.md).
-```toml
-[server]
-host = "127.0.0.1"
-port = 8080
-cookie_secure = false # set true when running behind HTTPS
+### Useful defaults
-[tor]
-enabled = false
-tor_only = false
-```
+| Setting | Default |
+| --- | --- |
+| New registrations | Enabled |
+| Anonymous posting | Disabled |
+| Registration CAPTCHA | Disabled |
+| Maximum post length | 280 characters |
+| Text-edit window | 15 seconds |
+| Attachments per post | Up to 4 images or 1 video; 4 total |
+| Image / video upload limit | 50 MiB / 150 MiB per file |
+| Account deletion grace period | 30 days |
+| Automatic backups | Disabled |
+| Tor | Disabled |
-### Tor only
+Limits can be changed in the editor within the ranges the app accepts. The `posts.allow_hashtags` field is reserved; current hashtag extraction does not depend on it. `tor.display_onion_address` must stay blank: the app displays its actual running onion address.
-```toml
-[server]
-host = "127.0.0.1"
-port = 8080
+## Runtime storage
-[tor]
-enabled = true
-tor_only = true # binds only the loopback Arti forwarder
-data_dir = "tor"
-onion_service_name = "microblog"
-bootstrap_timeout_secs = 120
-max_concurrent_streams = 512
-```
+The **data directory** holds your site. Always point administrator commands at the same directory used by `serve`.
-### Dual mode (clearnet + onion simultaneously)
+- With the README commands, `./rustpost-data` is relative to the terminal's current folder. Changing folders changes what that relative path means.
+- With no `--data-dir`, RustPost uses `rustpost-data` beside the executable.
+- For a service, use an absolute path such as `/srv/rustpost` to avoid ambiguity.
+- In the documented Docker setup, the data directory is `/data` in a persistent volume.
-```toml
-[server]
-host = "127.0.0.1"
-port = 8080
+The directory contains `settings.toml`, `db/rustpost.sqlite3`, `uploads/`, `assets/`, `backups/`, `logs/`, `tmp/`, and Tor state. Keep it private and out of Git; Unix installations restrict its directory permissions to `0700`.
-[tor]
-enabled = true
-tor_only = false # clearnet starts immediately; onion boots in background
-data_dir = "tor"
-onion_service_name = "microblog"
-bootstrap_timeout_secs = 120
-max_concurrent_streams = 512
-```
+Legacy `app.sqlite3` files at the data-directory root are moved to `db/rustpost.sqlite3` on startup. If both exist, startup reports a conflict and preserves both. Back up and investigate the two copies instead of deleting one blindly.
-### Rate limiting
+## Administrator accounts
-All limits are configured under `[moderation]`:
+Use hidden prompts to create or reset an administrator. Run these from the executable's folder, with your actual data path:
-```toml
-[moderation]
-posts_per_minute = 5
-replies_per_minute = 10
-reposts_per_minute = 10
-account_creations_per_ip_per_day = 3
-failed_login_attempts_per_15m = 10
-anonymous_posts_per_ip_per_hour = 10
+```sh
+./rustpost-cli --data-dir ./rustpost-data create-admin-interactive
+./rustpost-cli --data-dir ./rustpost-data reset-admin-password-interactive USERNAME
```
-> Authenticated limits are keyed by user ID. Registration, failed login, and anonymous limits are keyed by direct peer IP. Forwarded headers are **not trusted by default**.
-
----
+Replace `USERNAME` with the administrator's username. On Windows, use `.\rustpost-cli.exe`. In Docker, use `docker exec -it rustpost rustpost-cli --data-dir /data` followed by the command.
-## Security model
+For someone else's ordinary account, the **Users** page can require a password change or revoke their active sessions. See [account controls](account-reference.md#admin-forced-password-reset-31) for what those actions allow and restrict.
-| Control | Implementation |
-|---|---|
-| Passwords | Argon2id — never stored in plaintext |
-| Session cookies | HttpOnly · SameSite=Lax · `Secure` controlled by config |
-| CSRF | All state-changing authenticated routes require a CSRF token |
-| Output escaping | User content is HTML-escaped before rendering |
-| Upload safety | Filenames ignored; content sniffed; stored under fixed upload roots |
-| SVG | Not an allowed default upload type |
-| Admin routes | Require an admin session **and** CSRF protection |
-| Client IPs | Rate limits use the direct peer IP; forwarded headers are not used. `trusted_proxy_cidrs` is present in settings but does not currently change this behavior. |
-| Tor key material | Not stored in SQLite, not logged, not rendered in UI or admin health |
+### Password input for automation
----
+Version 1.1.0 supports `create-admin USERNAME --password-stdin` and `reset-admin-password USERNAME --password-stdin`. They read one UTF-8 line, up to 4096 bytes, from redirected input. Supply it from a protected file or secret-management tool; avoid literal passwords in commands or shell history. Legacy positional passwords still work but can appear in process listings.
-## Tor / Arti
-
-RustPost embeds [Arti](https://gitlab.torproject.org/tpo/core/arti) (the Rust Tor implementation) directly in the binary. No external `tor` daemon required.
-
-Embedded Arti provides an onion-service transport option, not an anonymity or security guarantee. Real onion reachability depends on Tor network access, bootstrap, descriptor publication, and client routing; verify reachability from a separate Tor client before relying on it.
-The active onion address is shown by the running server in its startup/status output, public header, and admin health page; it is not derived from configuration alone.
-
-**Behavior by config:**
-
-| `tor.enabled` | `tor_only` | Behavior |
-|---|---|---|
-| `false` | — | No Arti tasks started. Pure clearnet. |
-| `true` | `false` | Clearnet binds immediately. Arti onion service starts in background. If Tor fails, clearnet keeps running and admin health reports the error. |
-| `true` | `true` | Only a loopback listener is bound for Arti forwarding. Startup **fails** if Arti/onion startup fails. |
-
-**Current pinned Arti/Tor crates in `Cargo.toml`:**
-
-```
-arti-client = 0.46.0 # bootstraps the embedded Tor client and onion service
-tor-hsservice = 0.46.0 # onion-service config, handle, and rendezvous streams
-tor-proto = 0.46.0 # inspect and accept incoming onion stream requests
-tor-cell = 0.46.0 # cell-level protocol handling
-tor-rtcompat = 0.46.0 # Tokio-compatible Arti runtime
-rustls = 0.23 # ring crypto provider required by Arti's rustls stack
-```
-
-> **Dependency note:** `cargo tree -i libsqlite3-sys` should show exactly one version. RustPost uses `rusqlite` specifically to keep the `libsqlite3-sys` dependency unified with the Arti family.
+Hidden prompts require terminal stdin and stderr. Ctrl+C, Ctrl+D, or Esc cancels; validation or confirmation failures allow retry. On legacy Windows consoles, paste one line at a time. Forced termination or closing the terminal can prevent normal terminal cleanup.
-**Tor data layout:**
+## Backup and restore
-```
-rustpost-data/tor/
-├── cache/ ← Arti directory cache
-└── onion-service/
- └── state/ ← onion-service private keys (mode 0700 on Unix)
-```
+A full-site backup saves the database, settings, media, and assets. In **Admin → Backups**, create a backup and download it to another computer or storage location. A backup left only on the server cannot protect against losing that server.
-**Backups and Tor keys:**
+To create one from the terminal:
```sh
-rustpost-cli backup # excludes Tor keys (safe default)
-rustpost-cli backup --include-tor-keys # opt-in to include keys
-rustpost-cli restore archive.tar # rejects Tor key paths unless flag given
-rustpost-cli restore archive.tar --include-tor-keys
+./rustpost-cli --data-dir ./rustpost-data backup
```
-On Unix, the backup directory is mode `0700` and created backup archives are mode `0600`.
-Restore path validation rejects: absolute paths, traversal sequences, symlinks/hardlinks, duplicate entries, duplicate separators, Windows drive prefixes, backslash paths, encoded traversal or slash markers, and slash-like Unicode bypass characters.
+The command reports the archive path under `backups/`. Backup creation can run while the site is serving; it uses an online SQLite snapshot and coordinates application writes. Tor private keys are excluded unless you explicitly include them.
-**Live/local Tor smoke validation:**
+### Restore with the server stopped
-- 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.
-- 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`:
+Restoring replaces site data with the archive's contents. First preserve a separate backup of the current site and keep the restore archive outside the folders being replaced.
-```sh
-if nc -z 127.0.0.1 9150; then
- socks_proxy=127.0.0.1:9150
-elif nc -z 127.0.0.1 9050; then
- socks_proxy=127.0.0.1:9050
-else
- echo "SOCKS unavailable"
-fi
-
-test -n "${socks_proxy:-}" &&
- curl --socks5-hostname "$socks_proxy" -fsS "http://<56-character-v3-address>.onion/"
-```
+1. Stop RustPost and wait for it to exit. Stop any supervisor that would restart it.
+2. From the executable's folder, restore to the original data directory:
-No available SOCKS proxy is an environment limitation, not a RustPost product failure. The smoke can still validate Arti bootstrap, descriptor publication, UI onion-address consistency, local HTTP, and the loopback Arti forwarder.
+ ```sh
+ ./rustpost-cli --data-dir ./rustpost-data restore /path/to/ARCHIVE_NAME.tar
+ ./rustpost-cli --data-dir ./rustpost-data check
+ ```
----
+3. If restore and validation succeed, start it again with the usual `serve` command. Verify accounts, posts, and media in the browser.
-## Media and FFmpeg
-
-RustPost **boots and runs without `ffmpeg`**. Conversion is optional and detected at runtime. Admin health reports whether `ffmpeg` is present and whether WebP/VP9 encoders are available.
+Replace the archive path with your file. On Windows, use `.\rustpost-cli.exe` and a quoted Windows path if needed. Use the [Docker restore procedure](docker.md#backing-up-and-restoring) for a container.
-**When `ffmpeg` is detected:**
+**Version 1.1.0 disables browser/live restore** and takes a server lock during terminal restore. Older v1.0.0 guides described uploading a restore archive in Admin; that is not the current flow. If restore reports a recovery directory or rollback failure, preserve all recovery files and investigate the error before restarting. Startup deliberately refuses unresolved restore recovery state.
-- Images (JPEG, PNG, GIF, WebP) → converted to **WebP**
-- Videos (MP4, WebM, QuickTime) → converted to **WebM VP9** with `yuv420p` and explicit BT.709 color metadata to avoid browser playback issues
-- Image conversions: **120 s timeout**
-- Video conversions: **300 s timeout**
-- Conversion status (successes, fallbacks, stderr summaries) visible in admin media/health pages
+### Tor keys and backup contents
-**When `ffmpeg` is absent or conversion fails:** RustPost serves the original upload, provided it is an allowed content type.
+Add `--include-tor-keys` to `backup` to include private onion-service identity keys. Restoring an archive with those keys also requires `--include-tor-keys`. Protect such archives with access controls and encrypted storage. Without the old identity keys, a replacement onion service may have a different address.
-Profile pictures and banners follow the same media pipeline as post uploads.
+The backup manifest records application/schema versions, timestamps, components, sizes, SHA-256 hashes, and Tor-key inclusion. Durable content includes `db/rustpost.sqlite3`, `settings.toml`, upload variants, and assets. Temporary files, logs, old backups, caches, and symlinks are excluded.
----
+Restore validates entry paths and types, manifest hashes, settings, database integrity, foreign keys, and schema compatibility before replacing runtime files. It stages under the data directory, creates a pre-restore safety backup, and keeps rollback evidence if recovery cannot complete. Archive validation rejects traversal, absolute paths, links, duplicate entries, and other unsafe names. Allow free disk space for staging, the safety backup, and recovery copies.
-## Backup and restore
+### Automatic backups
-```sh
-# Create a backup (SQLite DB snapshot + settings + media/assets)
-rustpost-cli backup
+Enable scheduled backups in **Admin → Backups**. Defaults are a 1440-minute interval, at least the newest 10 automatic archives retained, and age cleanup after 30 days. Automatic backups start disabled.
-# Include Tor onion-service keys
-rustpost-cli backup --include-tor-keys
+Retention applies to `rustpost-auto-*.tar` archives only. It protects the configured newest count and never prunes manual or pre-restore backups. Review disk usage and copy backups off the host yourself. Automatic Tor-key inclusion is a separate explicit policy.
-# Restore into a fresh data directory
-rustpost-cli restore rustpost-20260526T....tar
-
-# Restore including Tor keys
-rustpost-cli restore rustpost-20260526T....tar --include-tor-keys
-```
+## Media and FFmpeg
-Backups are also available from **Admin → Backups**. The page supports manual backup creation, admin-only downloads, restore from uploaded `.tar` archives, automatic backup settings, safe retention controls, recent archive history, and no-JS form flows.
+RustPost runs without `ffmpeg`. When available and enabled, it converts accepted images to WebP, videos to WebM/VP9, and can generate video thumbnails. The default accepted types are JPEG, PNG, GIF, WebP, MP4, WebM, and QuickTime. Profile pictures and banners use the media pipeline too.
-Archive format:
+Conversion happens during upload, with bounded concurrent work. Image conversions have a 120-second timeout; videos have a 300-second timeout. Converted videos use `yuv420p` and BT.709 color metadata. An allowed original upload is used when conversion is unavailable or fails; that does not ensure every browser can play its codec.
-- Tar entries are written in deterministic order with normalized header metadata.
-- `manifest.toml` records the RustPost version, DB schema version, created timestamp, included components, runtime-relative paths, file sizes, SHA-256 hashes, and whether Tor keys are included.
-- The durable runtime state covered by the format is `db/rustpost.sqlite3`, `settings.toml`, `uploads/originals`, `uploads/images`, `uploads/videos`, `uploads/thumbs`, `assets`, and required empty runtime directories.
-- Runtime `tmp`, `logs`, `backups`, cache junk, Playwright artifacts, symlinks, and non-durable files are not included.
-- Tor onion-service private keys are excluded by default. `--include-tor-keys` or the admin checkbox is required to include or restore them. Restored Tor key files are permission-restricted on Unix.
+Check **Health** for executable/encoder availability and **Media jobs** for outcomes. `media.keep_original_uploads` controls retention alongside successful conversions and is false by default. Upload limits and encoding quality are listed in the [configuration reference](admin-configuration-audit.md).
-Restore safety:
+## Tor / Arti
-- Backups are treated as hostile input. RustPost validates the manifest, hashes, entry types, paths, settings file, SQLite integrity, foreign keys, and schema compatibility before touching live runtime files.
-- Restore stages into `tmp/`, creates a pre-restore safety backup, then swaps approved runtime roots. On failure it rolls back moved live paths and leaves the old runtime in place.
-- Concurrent backup/restore attempts are rejected with a lock under `tmp/`.
-- Admin-upload restore writes the restored files for the runtime, but the already-running process keeps its existing SQLite connection. Restart RustPost after a successful admin restore so it reopens the restored database and settings.
+Tor is optional. It gives the same site an onion address using embedded [Arti](https://gitlab.torproject.org/tpo/core/arti); you do not need a separate Tor daemon for the server.
-Automatic backups:
+To add onion access, edit the existing `[tor]` section or use the admin editor, then restart:
```toml
-[backup]
+[tor]
enabled = true
-backup_dir = "backups"
-automatic_enabled = false
-automatic_interval_minutes = 1440
-retention_keep_last = 10
-retention_max_age_days = 30
-automatic_include_tor_keys = false
+tor_only = false
```
-Automatic backups are disabled by default. Retention deletes only automatic archives (`rustpost-auto-*.tar`), always keeps the newest `retention_keep_last`, and never prunes manual or pre-restore safety backups.
-
----
-
-## Blocking, muting, and muted words
-
-RustPost enforces these rules in the database and query layer, not only in the UI.
+Leave the other generated Tor settings in place. In this dual mode, ordinary HTTP starts immediately and Tor starts in the background. If Tor fails, HTTP keeps serving and status reports the failure.
-| Action | Effect |
-|---|---|
-| Block | Removes follow relationships in both directions. The blocker and the blocked account cannot follow, reply to, quote, like, repost, bookmark, or mention each other's content, and neither account's posts appear in the other's feeds, threads, search results, mention suggestions, or notifications. The blocked account sees the profile without activity; neither account's display name, bio, or counts are removed from the database. Unblocking restores normal visibility and interactions. |
-| Mute | Hides the muted account's posts from the muter's feeds, search, mention suggestions, notifications, and profile activity. Mutes do not notify the muted account. |
-| Muted words | Per-user list, editable in Settings. Matching is case-insensitive (Unicode-aware), does not use regular expressions, and matches anywhere in the post text, so muting `cat` also hides `concatenate`. Matching applies to home, profile, media, likes, search, and quote previews, and to notification previews for other people's posts. Your own posts are never hidden by your own muted words. |
+For onion-only access, set both `enabled = true` and `tor_only = true`. Only the local Arti forwarder listens; startup fails if Tor cannot start. Verify onion access before depending on this mode. Onion HTTP uses virtual port 80; custom onion virtual ports are not configurable.
-Block and mute rows are deleted when either account is deleted, and deleting a post or account removes the associated media files once no other row references them.
+The running server supplies the onion address shown in status, the public header, and admin health. Keep `display_onion_address` empty. State lives under `tor/cache/` and `tor/onion-service/state/`; changing the service name or moving keys without a migration can change the site's identity.
+### Verify access
----
-
-## Release verification
-
-*Live-flow sweep: **August 15, 2026**. Rust release gates and advisory review: **September 29, 2026**.*
-
-Release validation distinguishes the required Rust gates from the dependency-advisory review:
+Tor status, address assignment, and descriptor publication are useful diagnostics, but **external reachability needs a separate Tor client**. Open the displayed address in Tor Browser. If using its SOCKS proxy at `127.0.0.1:9150` (or a system Tor proxy at `127.0.0.1:9050`), replace the placeholder below with the actual address:
```sh
-cargo fmt --all --check
-cargo build --release --bins
-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 audit
+curl --socks5-hostname 127.0.0.1:9150 -fsS 'http://YOUR_ACTUAL_ONION_ADDRESS.onion/'
```
-The format, release build, strict Clippy, and test commands must pass. `cargo audit` is reviewed separately and is **not fully clean** for v1.0.0 because of this documented upstream exception:
-
-> **Accepted v1.0.0 upstream audit exception:** `RUSTSEC-2023-0071` affects transitive `rsa 0.9.10` through the current pinned Arti/Tor dependency family. The advisory reports a potential key-recovery timing side channel, and no fixed upgrade is currently available. This is an upstream dependency risk, not a verified RustPost application vulnerability. Re-evaluate it when updating Arti or before the next release.
-
-`cargo audit` also reports unmaintained transitive `bincode 2.0.1` (`RUSTSEC-2025-0141`) and `paste 1.0.15` (`RUSTSEC-2024-0436`) dependencies inherited through the Arti dependency family. These are tracked as upstream maintenance caveats, not RustPost application vulnerabilities.
+The v3 hostname has 56 characters before `.onion`. Without an available SOCKS proxy or Tor network access, only local HTTP and server-side status can be checked. Tor transport alone does not guarantee anonymity.
-
-Verified locally
-
-- Fresh `--data-dir` boot creates `settings.toml`, `db/rustpost.sqlite3`, upload roots, temp upload staging, backup/log dirs, and Tor state dirs.
-- `rustpost-cli check` passes on a fresh data directory with `tor.enabled = false`.
-- Clearnet serving on `127.0.0.1:8080` loads `/home`.
-- Registration with CAPTCHA, login, post creation, replies, quote reposts, repost rendering, likes, bookmarks, followers/following pages, notifications, admin health, and CSRF-protected logout all work through live HTTP/browser flows.
-- Anonymous posting is disabled by default — anonymous users cannot see the composer and anonymous post attempts are rejected.
-- Non-admin users cannot access admin health; anonymous users cannot access authenticated pages.
-- `ffmpeg` 8.1.1 detected with WebP and VP9 support. Image, profile picture/banner, and small video uploads live-tested; WebP and WebM outputs produced; admin media/health pages reported conversion state correctly.
-- Normal backups include DB, settings, and media; exclude Tor keys. `--include-tor-keys` includes them only when explicitly requested. Restores into fresh data directories completed and `check` passed with and without controlled test Tor key material.
-- Backup archive names include subsecond precision — no same-second overwrite collisions.
-- Tor health/status fields render in admin health with Tor disabled in the current local sweep.
-- `rustpost-cli --version`, `rustpost-cli --help`, and `rustpost-cli check` report the final release CLI and pass on a fresh data directory.
-
-
-
-
-Partially verified / environment-dependent
+## CLI reference
-- Live Tor reachability depends on Tor network access, descriptor publication time, and a separate Tor client. The June 2026 live/local smoke verified embedded Arti bootstrap and descriptor publication; onion-over-SOCKS reachability remained untested because no local SOCKS proxy was available.
-- Tor private key material was not rendered in admin health or normal logs during the sweep. Operational text may reference key paths or the `--include-tor-keys` flag but does not print key contents.
+Prefix each command with the executable and your data directory, for example `./rustpost-cli --data-dir ./rustpost-data`. Use `--help` or `COMMAND --help` for the installed build's exact options.
-
+| Command | Purpose |
+| --- | --- |
+| `init` | Create runtime directories and default settings |
+| `check` | Read settings and inspect optional services/schema; does not migrate or initialize |
+| `serve` | Start the website; also the default when no subcommand is supplied |
+| `create-admin-interactive` | Create an admin with hidden prompts |
+| `reset-admin-password-interactive USERNAME` | Reset an admin password with hidden prompts |
+| `create-admin USERNAME --password-stdin` | Create an admin from protected redirected input |
+| `reset-admin-password USERNAME --password-stdin` | Reset an admin from protected redirected input |
+| `backup [--include-tor-keys]` | Create an archive |
+| `restore ARCHIVE.tar [--include-tor-keys]` | Restore with the server stopped |
+| `seed-demo` | Seed an explicit disposable demo directory; see [demo guide](demo-preview.md) |
----
+Global console options are `--console auto|tui|plain|off` and `--ascii`. `--config PATH` selects a settings file; `--data-dir PATH` selects site storage. Put both path options before the subcommand. They do not override individual settings. See the [terminal guide](terminal-console.md) for controls and measurement limits.
-## Known limitations
+`check` reports missing fresh databases, adoptable legacy schemas, and optional FFmpeg absence without treating them as corruption. It exits nonzero for incompatible/unreadable databases or layout conflicts. A legacy database is inspected in place.
-- **Tor verification requires network access** — may time out in restricted build or CI environments.
-- **Onion virtual port** — HTTP virtual port 80 is mapped to the RustPost listener; custom onion virtual ports are not yet configurable.
-- **Synchronous media conversion** — conversion happens inline during upload; no background queue.
-- **Reports and admin toggles** — present in schema and admin structure but functionality is minimal in this release.
-- **Search** — uses SQLite FTS5 with simple user matching and fixed result limits; no ranking tuning yet.
-- **UI polish** — server-rendered HTML/CSS covers the core flows, but final visual design and broader accessibility review are still future work.
+## Further reference
----
+- [Deployment and security](deployment.md), including HTTPS and trusted proxies.
+- [Software updates](software-updates.md), including version comparisons and manual replacement.
+- [Development checks](development.md#checks) and [dependency review](deployment.md#security-and-dependency-review).
+- [Account controls](account-and-instance-features.md), including blocking, muting, and transfers.
diff --git a/docs/rust-1.99-upgrade.md b/docs/rust-1.99-upgrade.md
new file mode 100644
index 0000000..14aa150
--- /dev/null
+++ b/docs/rust-1.99-upgrade.md
@@ -0,0 +1,47 @@
+# Compiler upgrade history
+
+For a current build, follow [Development](development.md): both the compiler pin and minimum supported version are now **Rust 1.99.1**. You do not need this historical report to run RustPost.
+
+[All guides](README.md) · [Current checks](development.md#checks) · [Changelog](../CHANGELOG.md)
+
+## Historical report: October 1, 2026
+
+The record below describes the earlier 1.99.0 migration and its validation at that time. It is preserved as project history. The old 1.91 minimum, floating-stable CI checks, updater binary, release-tool tests, and application test counts do not describe the current tree. Version 1.1.0 removed the installer machinery and uses a manual release-link check; later work added terminal/CLI regressions.
+
+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 recorded at the time
+
+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..857192c
--- /dev/null
+++ b/docs/software-updates.md
@@ -0,0 +1,45 @@
+# Updating RustPost
+
+Updates are installed by the person running the site. Save a backup, download the new release, stop the app, replace the executable, and start it with the **same data directory**.
+
+[All guides](README.md) · [Backups](operator-guide.md#backup-and-restore) · [Docker upgrades](docker.md#replacing-or-upgrading-the-container)
+
+## Check for a release
+
+In v1.1.0, open **Admin → Software updates** and choose **Check for updates**. RustPost asks GitHub for the latest published stable release. If its version is newer than the running package version, the page shows a link to that release.
+
+The check does not download, install, restart, or replace anything. Drafts and prereleases are excluded. Network failures leave the site running and show that the check could not complete; you can visit [GitHub Releases](https://github.com/csd113/RustPost/releases) directly or try later.
+
+The v1.1.0 build reports version **1.1.0**. The checker compares version numbers, not Git commits, so “up to date” does not mean a development checkout matches the latest dev commit or a published archive. Before v1.1.0 is published, a prepared 1.1.0 build can also report “up to date” against the older published release. Keep the source commit with your build records.
+
+There is no automatic updater service, local updater IPC endpoint, or privileged installation helper in v1.1.0. Older upgrade notes that describe those components are historical.
+
+## Replace a downloaded app
+
+1. Read the target release notes and check platform compatibility. Download its archive and checksum, then [verify the download](getting-started.md#verify-a-download).
+2. Create a [full-site backup](operator-guide.md#backup-and-restore) and copy it off the machine. If you need to preserve an onion identity, explicitly include and protect its keys.
+3. Stop RustPost and wait for shutdown. Pause any service manager that would restart it.
+4. Keep the old executable as a rollback reference. Extract the new executable into the app folder **without deleting or replacing `rustpost-data`**. Use `chmod +x rustpost-cli` on Linux/macOS if necessary.
+5. Start it with your original command and data path:
+
+ ```sh
+ ./rustpost-cli --data-dir ./rustpost-data serve
+ ```
+
+ On Windows, use `.\rustpost-cli.exe --data-dir .\rustpost-data serve`. A service should use its original absolute data path.
+
+6. Log in and verify accounts, posts, media, and administrator access. Keep the backup until you have confirmed the new version works.
+
+Startup may apply forward database migrations. Do not assume replacing the executable with an older one can read an upgraded database. If rollback is needed, stop the app and use a compatible old binary with the pre-upgrade backup, following the [offline restore procedure](operator-guide.md#restore-with-the-server-stopped). Keep newer data separately if it needs recovery.
+
+## Containers and source builds
+
+For Docker, [replace the container](docker.md#replacing-or-upgrading-the-container) using the intended published image and retain the same `/data` volume. Restarting an existing container does not pull a newer image.
+
+For source builds, record the commit, build and validate it using the [development guide](development.md), then follow the same backup/stop/replace/start process. Do not overwrite a running executable or assume a newer commit has a newer package version.
+
+## Release-check details
+
+The admin check is CSRF-protected, permits one in-flight request, and allows five checks per administrator per 15 minutes. It uses GitHub's latest-release API, a 15-second total timeout, a 5-second connection timeout, a 512 KiB response cap, and no HTTP redirects. The displayed link is constructed from a validated stable version tag. The result is held in process memory, so restarting clears it.
+
+A release link is a discovery aid. Verify the downloaded artifact and review the release notes before installation.
diff --git a/docs/terminal-console.md b/docs/terminal-console.md
new file mode 100644
index 0000000..02a9a1f
--- /dev/null
+++ b/docs/terminal-console.md
@@ -0,0 +1,51 @@
+# Terminal console
+
+The terminal window lets you see how your running RustPost site is doing. It stays open while the app serves the website; use a browser for posting and administration.
+
+[All guides](README.md) · [First run](getting-started.md) · [Operator guide](operator-guide.md)
+
+This guide describes the live console in v1.1.0. The published v1.0.0 build has an older display.
+
+## Everyday controls
+
+| Key | Action |
+| --- | --- |
+| 1–6 or Tab / Shift+Tab | Switch between Overview, Tasks, Content, Logs, System, and Configuration |
+| Arrows or J/K | Scroll the current view |
+| Page Up / Page Down, Home / End | Move through longer views |
+| R | Request a status refresh |
+| ? or H | Open or close help |
+| Q | Ask to stop the server; confirm to shut down |
+| Ctrl+C | Request graceful shutdown directly |
+
+The website becomes unavailable when the process stops. Wait for shutdown to finish before starting it again or restoring data.
+
+## Choose the display
+
+From the executable's folder:
+
+```sh
+./rustpost-cli --data-dir ./rustpost-data --console plain serve
+```
+
+Use `.\rustpost-cli.exe` on Windows. `auto` chooses a full-screen display when the terminal supports it; `plain` prints line reports; `off` disables the console and its samplers. `tui` requests the full-screen view but can still fall back when terminal setup fails. Add `--ascii` when your terminal cannot display account names or other Unicode text correctly.
+
+## Understand status before acting
+
+**STALE** means RustPost is showing its last successful measurement with an age and reason. **UNAVAILABLE** means there is no usable value. Neither means the corresponding count is zero.
+
+An onion address or descriptor status does not prove that someone else can connect through Tor. Test it from a separate Tor client. Log summaries are bounded operational information, not a complete audit record.
+
+## Sampling, logging, and terminal details
+
+`serve` selects the full-screen console when stdin/stdout are terminals and `TERM` is not `dumb`. Use `--console auto|tui|plain|off`; terminal initialization or capability failure falls back to plain reports. `off` disables the console and its samplers. Command results stay on stdout; operational reports/logs use stderr. Redirected/dumb output contains no ANSI. The console uses monochrome text and ASCII borders; `--ascii` displays Unicode data as explicit code points.
+
+Every content/help view scrolls, including below 44×14. Raw mode and the alternate screen restore when stopping begins, while plain stopping reports can continue.
+
+Health and freshness are independent. Counts update every 3 seconds through a separate read-only SQLite connection with a 250 ms query budget; forced refreshes coalesce and run at most once/second. DB main/WAL/SHM file lengths update separately every 3 seconds. Upload file lengths are scanned every 30 seconds without following symlinks, with a 10,000-entry/200 ms cooperative budget. Incomplete/failed collections preserve last-good values as **STALE** with age/reason; missing values are **UNAVAILABLE**. File lengths are not allocated disk blocks. OS filesystem calls themselves have no hard deadline.
+
+Listeners display actual bound sockets, including ephemeral ports. Tor distinguishes address assignment, Arti introduction-point/descriptor status, forwarding errors, and the age of observed traffic; current external reachability remains unverified. Task failures and last failure age are retained for the process lifetime independently of log filtering. HTTP counters count handlers and 5xx responses; RSS/CPU and prior-process task outcomes are explicitly unavailable.
+
+Logs retain the last 200 safe event summaries and write `logs/events.log` (1 MiB plus one previous file). Only allowlisted messages/diagnostic categories and source metadata are retained; arbitrary diagnostic fields, request data and secrets are omitted. The Logs view identifies queue drops, evictions and persistence failures. Bounded nonblocking queues isolate logging IO; event-sink drain waits at most one second at exit. This is best-effort event retention, not a durable audit journal.
+
+Shutdown owns the scheduler tasks and finishes admitted backup/maintenance work. HTTP drain is capped at 30 seconds; transactional backup work is not force-aborted. There is no hard whole-process bound for a stuck filesystem. Native Windows console/VT/code-page behavior and Linux SSH/tmux behavior require native interactive validation; portable builds alone do not establish it.
diff --git a/rust-toolchain.toml b/rust-toolchain.toml
new file mode 100644
index 0000000..57ef39f
--- /dev/null
+++ b/rust-toolchain.toml
@@ -0,0 +1,4 @@
+[toolchain]
+channel = "1.99.1"
+profile = "minimal"
+components = ["clippy", "rustfmt"]
diff --git a/src/account.rs b/src/account.rs
index 78f9c7c..bea252f 100644
--- a/src/account.rs
+++ b/src/account.rs
@@ -641,7 +641,7 @@ fn remove_journal(journal_path: &Path, user_id: i64) {
///
/// The journal lives under [`RuntimePaths::tmp_dir`] and records the numeric
/// owner id on the first line followed by one absolute media path per line.
-/// Only paths that already passed [`safe_media_path`] are written, so recovery
+/// Only paths that already passed `safe_media_path` are written, so recovery
/// can trust the validation and re-check it. Callers must roll the database
/// transaction back when this fails.
fn write_pending_media_deletion_journal(
@@ -701,8 +701,8 @@ fn sync_directory(path: &Path) -> std::io::Result<()> {
/// Removes media files left behind by interrupted account deletions.
///
/// Scans [`RuntimePaths::tmp_dir`] for pending-media-deletion journals, at most
-/// [`MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS`] per call. Each recorded
-/// path is re-validated with [`safe_media_path`]; unsafe entries are skipped
+/// `MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS` per call. Each recorded
+/// path is re-validated with `safe_media_path`; unsafe entries are skipped
/// and logged, and malformed journals are retained for operator review. A
/// journal is removed only when every recorded file was removed or was already
/// missing. Returns the number of journals fully recovered.
@@ -876,1302 +876,3 @@ fn parse_pending_media_deletion_journal(
}
Ok((user_id, media_files))
}
-
-#[cfg(test)]
-mod tests {
- use super::*;
- use crate::{auth, config::Settings, db, social};
-
- async fn fixture() -> (
- tempfile::TempDir,
- RuntimePaths,
- SqlitePool,
- Settings,
- i64,
- i64,
- ) {
- let temp = tempfile::tempdir().expect("temp dir");
- let paths = RuntimePaths::from_data_dir(temp.path().join("data"));
- paths.ensure().expect("runtime paths");
- let pool = db::connect(&paths.database_path).await.expect("connect");
- db::migrate(&pool).await.expect("migrate");
- let settings = Settings::default();
- let alice = auth::register_user(&pool, &settings, "alice", "very secure password", false)
- .await
- .expect("alice");
- let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false)
- .await
- .expect("bob");
- (temp, paths, pool, settings, alice, bob)
- }
-
- #[tokio::test]
- async fn delete_account_rejects_wrong_password() {
- let (_temp, paths, pool, _settings, alice, _bob) = fixture().await;
-
- let result = delete_account(&pool, &paths, alice, "wrong password").await;
-
- assert_eq!(
- result.expect_err("wrong password"),
- DeleteAccountError::WrongPassword
- );
- let users: i64 = pool
- .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?))
- .await
- .expect("count");
- assert_eq!(users, 2);
- }
-
- #[tokio::test]
- async fn delete_account_scrubs_owned_rows_and_media_files() {
- let (_temp, paths, pool, settings, alice, bob) = fixture().await;
- let post = social::create_post(&pool, &settings, Some(alice), "alice post", None, &[])
- .await
- .expect("post");
- social::follow(&pool, bob, alice).await.expect("follow");
- social::like(&pool, bob, post).await.expect("like");
- social::bookmark(&pool, bob, post).await.expect("bookmark");
- social::repost(&pool, bob, post).await.expect("repost");
- social::block(&pool, bob, alice).await.expect("block");
- social::mute(&pool, bob, alice).await.expect("mute");
- social::add_muted_word(&pool, alice, "secret")
- .await
- .expect("muted word");
- let alice_actor = format!("user:{alice}");
- crate::rate_limit::record(&pool, crate::rate_limit::Scope::Post, &alice_actor)
- .await
- .expect("rate limit");
- let media_path = paths.uploads_images.join("alice.webp");
- fs::write(&media_path, b"image").expect("media file");
- let thumb_path = paths.uploads_thumbs.join("alice-thumb.webp");
- fs::write(&thumb_path, b"thumb").expect("thumb file");
- let media_path_string = media_path.to_string_lossy().to_string();
- let thumb_path_string = thumb_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, thumbnail_path) VALUES (?, 'alice.webp', ?, '/uploads/images/alice.webp', 'image/webp', 'image', 5, ?)",
- params![alice, media_path_string, thumb_path_string],
- )?;
- let media_id = conn.last_insert_rowid();
- conn.execute(
- "UPDATE users SET profile_picture_media_id = ? WHERE id = ?",
- params![media_id, alice],
- )?;
- conn.execute(
- "INSERT INTO media_jobs (media_id, status) VALUES (?, 'converted')",
- [media_id],
- )?;
- Ok(())
- })
- .await
- .expect("media row");
-
- let summary = delete_account(&pool, &paths, alice, "very secure password")
- .await
- .expect("delete account");
-
- assert_eq!(summary.deleted_media_files, 2);
- assert!(!media_path.exists());
- assert!(!thumb_path.exists());
- let counts: (i64, i64, i64, i64, i64, i64, i64, i64, i64, i64) = pool
- .call(move |conn| {
- Ok((
- conn.query_row("SELECT COUNT(*) FROM users WHERE id = ?", [alice], |row| {
- row.get(0)
- })?,
- conn.query_row(
- "SELECT COUNT(*) FROM posts WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM media WHERE owner_user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row("SELECT COUNT(*) FROM likes", [], |row| row.get(0))?,
- conn.query_row("SELECT COUNT(*) FROM bookmarks", [], |row| row.get(0))?,
- conn.query_row("SELECT COUNT(*) FROM reposts", [], |row| row.get(0))?,
- conn.query_row("SELECT COUNT(*) FROM follows", [], |row| row.get(0))?,
- conn.query_row("SELECT COUNT(*) FROM blocks", [], |row| row.get(0))?,
- conn.query_row("SELECT COUNT(*) FROM mutes", [], |row| row.get(0))?,
- conn.query_row(
- "SELECT COUNT(*) FROM rate_limit_events WHERE actor = ?",
- [format!("user:{alice}")],
- |row| row.get(0),
- )?,
- ))
- })
- .await
- .expect("counts");
- assert_eq!(counts, (0, 0, 0, 0, 0, 0, 0, 0, 0, 0));
- }
-
- #[tokio::test]
- async fn delete_account_scrubs_media_inserted_after_password_check() {
- let (_temp, paths, pool, _settings, alice, _bob) = fixture().await;
- assert!(
- auth::verify_user_password(&pool, alice, "very secure password")
- .await
- .expect("password")
- );
- let media_path = paths.uploads_images.join("late.webp");
- fs::write(&media_path, b"late").expect("late media file");
- let media_path_string = media_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'late.webp', ?, '/uploads/images/late.webp', 'image/webp', 'image', 4)",
- params![alice, media_path_string],
- )?;
- Ok(())
- })
- .await
- .expect("late media row");
-
- let summary = delete_account(&pool, &paths, alice, "very secure password")
- .await
- .expect("delete account");
-
- assert_eq!(summary.deleted_media_files, 1);
- assert!(!media_path.exists());
- let rows: i64 = pool
- .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM media", [], |row| row.get(0))?))
- .await
- .expect("media count");
- assert_eq!(rows, 0);
- }
-
- #[tokio::test]
- async fn delete_account_keeps_shared_media_files_referenced_by_other_rows() {
- let (_temp, paths, pool, _settings, alice, bob) = fixture().await;
- let media_path = paths.uploads_images.join("shared.webp");
- fs::write(&media_path, b"shared").expect("shared media file");
- let media_path_string = media_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice.webp', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 6)",
- params![alice, media_path_string],
- )?;
- let media_path_string = media_path_string.clone();
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'bob.webp', ?, '/uploads/images/shared.webp', 'image/webp', 'image', 6)",
- params![bob, media_path_string],
- )?;
- Ok(())
- })
- .await
- .expect("shared media rows");
-
- let summary = delete_account(&pool, &paths, alice, "very secure password")
- .await
- .expect("delete account");
-
- assert_eq!(summary.deleted_media_files, 0);
- assert!(media_path.exists());
- let bob_rows: i64 = pool
- .call(move |conn| {
- Ok(conn.query_row(
- "SELECT COUNT(*) FROM media WHERE owner_user_id = ?",
- [bob],
- |row| row.get(0),
- )?)
- })
- .await
- .expect("bob media count");
- assert_eq!(bob_rows, 1);
- }
-
- #[tokio::test]
- async fn delete_account_promotes_shared_canonical_media_for_remaining_rows() {
- let (_temp, paths, pool, _settings, alice, bob) = fixture().await;
- let media_path = paths.uploads_images.join("promoted.webp");
- fs::write(&media_path, b"shared").expect("shared media file");
- let media_path_string = media_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (id, owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (100, ?, 'alice.webp', ?, '/uploads/images/promoted.webp', 'image/webp', 'image', 6, 'alice-raw', 'shared-normalized')",
- params![alice, media_path_string],
- )?;
- let media_path_string = media_path_string.clone();
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256, canonical_media_id) VALUES (?, 'bob.webp', ?, '/uploads/images/promoted.webp', 'image/webp', 'image', 6, 'bob-raw', 'shared-normalized', 100)",
- params![bob, media_path_string],
- )?;
- Ok(())
- })
- .await
- .expect("shared media rows");
-
- let summary = delete_account(&pool, &paths, alice, "very secure password")
- .await
- .expect("delete account");
-
- assert_eq!(summary.deleted_media_files, 0);
- assert!(media_path.exists());
- let bob_canonical: Option = pool
- .call(move |conn| {
- Ok(conn.query_row(
- "SELECT canonical_media_id FROM media WHERE owner_user_id = ?",
- [bob],
- |row| row.get(0),
- )?)
- })
- .await
- .expect("bob canonical");
- assert_eq!(bob_canonical, None);
- }
-
- #[tokio::test]
- async fn delete_account_treats_file_cleanup_failure_as_success_after_db_scrub() {
- let (_temp, paths, pool, _settings, alice, _bob) = fixture().await;
- let media_path = paths.uploads_images.join("directory-media");
- fs::create_dir(&media_path).expect("media directory");
- let media_path_string = media_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'directory-media', ?, '/uploads/images/directory-media', 'image/webp', 'image', 1)",
- params![alice, media_path_string],
- )?;
- Ok(())
- })
- .await
- .expect("media row");
-
- let summary = delete_account(&pool, &paths, alice, "very secure password")
- .await
- .expect("delete account");
-
- assert_eq!(summary.deleted_media_files, 0);
- assert!(media_path.exists());
- let user_exists: bool = pool
- .call(move |conn| {
- Ok(conn
- .query_row("SELECT 1 FROM users WHERE id = ?", [alice], |_| Ok(()))
- .optional()?
- .is_some())
- })
- .await
- .expect("user lookup");
- assert!(!user_exists);
- }
-
- #[tokio::test]
- async fn delete_account_rejects_unsafe_media_path_before_db_changes() {
- let (_temp, paths, pool, _settings, alice, _bob) = fixture().await;
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'bad.webp', '/tmp/rustpost-outside.webp', '/uploads/images/bad.webp', 'image/webp', 'image', 1)",
- [alice],
- )?;
- Ok(())
- })
- .await
- .expect("unsafe media row");
-
- let result = delete_account(&pool, &paths, alice, "very secure password").await;
-
- assert!(matches!(
- result,
- Err(DeleteAccountError::UnsafeMediaPath(path)) if path == "/tmp/rustpost-outside.webp"
- ));
- let exists: bool = pool
- .call(move |conn| {
- Ok(conn
- .query_row("SELECT 1 FROM users WHERE id = ?", [alice], |_| Ok(()))
- .optional()?
- .is_some())
- })
- .await
- .expect("user exists");
- assert!(exists);
- }
-
- async fn user_exists(pool: &SqlitePool, user_id: i64) -> bool {
- pool.call(move |conn| {
- Ok(conn
- .query_row("SELECT 1 FROM users WHERE id = ?", [user_id], |_| Ok(()))
- .optional()?
- .is_some())
- })
- .await
- .expect("user lookup")
- }
-
- async fn expire_deletion_deadline(pool: &SqlitePool, user_id: i64) {
- pool.call(move |conn| {
- conn.execute(
- "UPDATE users SET deletion_scheduled_at = datetime('now', '-1 second') WHERE id = ?",
- [user_id],
- )?;
- Ok(())
- })
- .await
- .expect("expire deadline");
- }
-
- fn pending_media_journals(paths: &RuntimePaths) -> Vec {
- let Ok(entries) = fs::read_dir(&paths.tmp_dir) else {
- return Vec::new();
- };
- let mut journals = Vec::new();
- for entry in entries.flatten() {
- let path = entry.path();
- if is_pending_media_deletion_journal(&path) {
- journals.push(path);
- }
- }
- journals.sort();
- journals
- }
-
- async fn insert_media_row(pool: &SqlitePool, user_id: i64, stored_path: &str) -> i64 {
- let stored_path = stored_path.to_owned();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'media.webp', ?, '/uploads/images/media.webp', 'image/webp', 'image', 5)",
- params![user_id, stored_path],
- )?;
- Ok(conn.last_insert_rowid())
- })
- .await
- .expect("media row")
- }
-
- #[tokio::test]
- async fn request_deletion_sets_window_and_is_idempotent() {
- let (_temp, _paths, pool, settings, alice, bob) = fixture().await;
-
- assert!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .is_none()
- );
- let first = request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- assert!(!first.requested_at.is_empty());
- assert!(first.scheduled_at > first.requested_at);
- let second = request_deletion(&pool, &settings, alice)
- .await
- .expect("repeat request");
- assert_eq!(first, second);
- assert_eq!(
- deletion_status(&pool, alice).await.expect("status"),
- Some(first)
- );
- assert!(
- deletion_status(&pool, bob)
- .await
- .expect("bob status")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn request_deletion_with_zero_grace_schedules_immediately() {
- let (_temp, _paths, pool, mut settings, alice, _bob) = fixture().await;
- settings.accounts.deletion_grace_period_days = 0;
-
- let request = request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
-
- assert_eq!(request.requested_at, request.scheduled_at);
- }
-
- #[tokio::test]
- async fn request_deletion_rejects_missing_and_deleted_accounts() {
- let (_temp, _paths, pool, settings, alice, _bob) = fixture().await;
-
- let missing = request_deletion(&pool, &settings, 9999).await;
- assert_eq!(
- missing.expect_err("missing account").to_string(),
- "account not found"
- );
-
- pool.call(move |conn| {
- conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?;
- Ok(())
- })
- .await
- .expect("mark deleted");
- let deleted = request_deletion(&pool, &settings, alice).await;
- assert_eq!(
- deleted.expect_err("deleted account").to_string(),
- "account not found"
- );
- }
-
- #[tokio::test]
- async fn cancel_deletion_clears_and_is_idempotent() {
- let (_temp, _paths, pool, settings, alice, _bob) = fixture().await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
-
- assert!(cancel_deletion(&pool, alice).await.expect("cancel"));
- assert!(!cancel_deletion(&pool, alice).await.expect("cancel again"));
- assert!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .is_none()
- );
-
- let re_requested = request_deletion(&pool, &settings, alice)
- .await
- .expect("re-request");
- assert!(!re_requested.requested_at.is_empty());
- }
-
- #[tokio::test]
- async fn finalize_due_deletions_ignores_future_and_null_deadlines() {
- let (_temp, paths, pool, settings, alice, bob) = fixture().await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize before deadline"),
- 0
- );
- assert!(user_exists(&pool, alice).await);
-
- pool.call(move |conn| {
- conn.execute(
- "UPDATE users SET deletion_scheduled_at = datetime('now', '+1 day') WHERE id = ?",
- [alice],
- )?;
- Ok(())
- })
- .await
- .expect("push deadline");
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize with future deadline"),
- 0
- );
- assert!(user_exists(&pool, alice).await);
- assert!(user_exists(&pool, bob).await);
- }
-
- #[tokio::test]
- async fn finalize_due_deletions_includes_the_exact_deadline() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- pool.call(move |conn| {
- conn.execute(
- "UPDATE users SET deletion_scheduled_at = CURRENT_TIMESTAMP WHERE id = ?",
- [alice],
- )?;
- Ok(())
- })
- .await
- .expect("set exact deadline");
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize at the deadline"),
- 1
- );
- }
-
- #[tokio::test]
- async fn finalize_due_deletions_removes_due_accounts_and_releases_handle() {
- let (_temp, paths, pool, settings, alice, bob) = fixture().await;
- social::create_post(&pool, &settings, Some(alice), "alice post", None, &[])
- .await
- .expect("post");
- social::follow(&pool, bob, alice).await.expect("follow");
- let media_path = paths.uploads_images.join("alice-due.webp");
- fs::write(&media_path, b"image").expect("media file");
- let media_path_string = media_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice-due.webp', ?, '/uploads/images/alice-due.webp', 'image/webp', 'image', 5)",
- params![alice, media_path_string],
- )?;
- conn.execute(
- "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)",
- params![bob, alice],
- )?;
- conn.execute(
- "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)",
- params![alice, bob],
- )?;
- conn.execute(
- "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (?, 'archive-1', 1)",
- [alice],
- )?;
- Ok(())
- })
- .await
- .expect("account rows");
- crate::identity::change_username(&pool, &settings, alice, "alice_scheduled")
- .await
- .expect("rename alice");
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request deletion");
- expire_deletion_deadline(&pool, alice).await;
-
- let removed = finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize");
-
- assert_eq!(removed, 1);
- assert!(!media_path.exists());
- let counts: (i64, i64, i64, i64, i64, i64) = pool
- .call(move |conn| {
- Ok((
- conn.query_row("SELECT COUNT(*) FROM users WHERE id = ?", [alice], |row| {
- row.get(0)
- })?,
- conn.query_row(
- "SELECT COUNT(*) FROM posts WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM media WHERE owner_user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM follow_requests WHERE requester_id = ? OR target_id = ?",
- params![alice, alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM username_history WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM account_imports WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- ))
- })
- .await
- .expect("counts");
- assert_eq!(counts, (0, 0, 0, 0, 0, 0));
- assert!(user_exists(&pool, bob).await);
- crate::auth::register_user(&pool, &settings, "alice", "very secure password", false)
- .await
- .expect("historical handle released");
- crate::auth::register_user(
- &pool,
- &settings,
- "alice_scheduled",
- "very secure password",
- false,
- )
- .await
- .expect("current handle released");
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize again"),
- 0
- );
- }
-
- #[tokio::test]
- async fn finalize_due_deletions_yields_to_cancellation() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
-
- assert!(cancel_deletion(&pool, alice).await.expect("cancel first"));
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize"),
- 0
- );
- assert!(user_exists(&pool, alice).await);
- assert!(!cancel_deletion(&pool, alice).await.expect("cancel again"));
- }
-
- #[tokio::test]
- async fn delete_account_scrubs_follow_requests_username_history_and_imports() {
- let (_temp, paths, pool, settings, alice, bob) = fixture().await;
- pool.call(move |conn| {
- conn.execute(
- "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)",
- params![bob, alice],
- )?;
- conn.execute(
- "INSERT INTO follow_requests (requester_id, target_id) VALUES (?, ?)",
- params![alice, bob],
- )?;
- conn.execute(
- "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (?, 'archive-1', 1)",
- [alice],
- )?;
- Ok(())
- })
- .await
- .expect("account rows");
- crate::identity::change_username(&pool, &settings, alice, "alice_immediate")
- .await
- .expect("rename alice");
-
- delete_account(&pool, &paths, alice, "very secure password")
- .await
- .expect("delete account");
-
- let counts: (i64, i64, i64) = pool
- .call(move |conn| {
- Ok((
- conn.query_row(
- "SELECT COUNT(*) FROM follow_requests WHERE requester_id = ? OR target_id = ?",
- params![alice, alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM username_history WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM account_imports WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- ))
- })
- .await
- .expect("counts");
- assert_eq!(counts, (0, 0, 0));
- }
-
- #[tokio::test]
- async fn concurrent_finalizations_delete_exactly_once() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- social::create_post(&pool, &settings, Some(alice), "alice post", None, &[])
- .await
- .expect("post");
- let media_path = paths.uploads_images.join("concurrent.webp");
- fs::write(&media_path, b"image").expect("media file");
- insert_media_row(&pool, alice, &media_path.to_string_lossy()).await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
-
- let (first, second) = tokio::join!(
- finalize_due_deletions(&pool, &paths),
- finalize_due_deletions(&pool, &paths)
- );
- let first = first.expect("first finalize");
- let second = second.expect("second finalize");
-
- assert_eq!(
- first + second,
- 1,
- "exactly one finalizer must claim the account"
- );
- assert!(!user_exists(&pool, alice).await);
- assert!(!media_path.exists());
- assert!(pending_media_journals(&paths).is_empty());
- let counts: (i64, i64) = pool
- .call(move |conn| {
- Ok((
- conn.query_row(
- "SELECT COUNT(*) FROM posts WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM media WHERE owner_user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- ))
- })
- .await
- .expect("counts");
- assert_eq!(counts, (0, 0));
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("third finalize"),
- 0
- );
- }
-
- #[tokio::test]
- async fn cancellation_before_claim_keeps_account_intact() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- social::create_post(&pool, &settings, Some(alice), "alice post", None, &[])
- .await
- .expect("post");
- let media_path = paths.uploads_images.join("cancelled.webp");
- fs::write(&media_path, b"image").expect("media file");
- insert_media_row(&pool, alice, &media_path.to_string_lossy()).await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
-
- assert!(cancel_deletion(&pool, alice).await.expect("cancel"));
- assert!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .is_none()
- );
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize after cancellation"),
- 0
- );
- assert!(user_exists(&pool, alice).await);
- assert!(media_path.exists());
- let posts: i64 = pool
- .call(move |conn| {
- Ok(conn.query_row(
- "SELECT COUNT(*) FROM posts WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?)
- })
- .await
- .expect("posts");
- assert_eq!(posts, 1);
- assert_eq!(pending_media_journals(&paths).len(), 0);
- }
-
- #[tokio::test]
- async fn cancellation_after_finalization_cannot_resurrect_account() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize"),
- 1
- );
-
- assert!(!cancel_deletion(&pool, alice).await.expect("cancel"));
- assert!(!user_exists(&pool, alice).await);
- assert!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn concurrent_cancel_and_finalize_never_half_scrub() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- social::create_post(&pool, &settings, Some(alice), "alice post", None, &[])
- .await
- .expect("post");
- let media_path = paths.uploads_images.join("racing.webp");
- fs::write(&media_path, b"image").expect("media file");
- insert_media_row(&pool, alice, &media_path.to_string_lossy()).await;
- let _session = auth::create_session(&pool, alice).await.expect("session");
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
-
- let (cancelled, finalized) = tokio::join!(
- cancel_deletion(&pool, alice),
- finalize_due_deletions(&pool, &paths)
- );
- let cancelled = cancelled.expect("cancel");
- let finalized = finalized.expect("finalize");
-
- let present = user_exists(&pool, alice).await;
- let counts: (i64, i64, i64) = pool
- .call(move |conn| {
- Ok((
- conn.query_row(
- "SELECT COUNT(*) FROM posts WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM sessions WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM media WHERE owner_user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- ))
- })
- .await
- .expect("counts");
- if present {
- assert!(cancelled, "a surviving account must have been cancelled");
- assert_eq!(finalized, 0);
- assert_eq!(
- counts,
- (1, 1, 1),
- "cancellation must leave every row intact"
- );
- assert!(media_path.exists());
- assert!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .is_none()
- );
- } else {
- assert!(!cancelled, "a removed account must reject cancellation");
- assert_eq!(finalized, 1);
- 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!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("sweep after the race"),
- 0
- );
- assert_eq!(user_exists(&pool, alice).await, present);
- }
-
- #[tokio::test]
- async fn unsafe_media_path_rolls_finalization_back_until_fixed() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- social::create_post(&pool, &settings, Some(alice), "alice post", None, &[])
- .await
- .expect("post");
- insert_media_row(&pool, alice, "../escape.webp").await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
- let deadline = deletion_status(&pool, alice)
- .await
- .expect("status")
- .expect("pending")
- .scheduled_at;
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize with unsafe path"),
- 0
- );
- assert!(user_exists(&pool, alice).await);
- assert_eq!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .expect("still pending")
- .scheduled_at,
- deadline,
- "a rolled-back finalization must keep the original deadline"
- );
- let counts: (i64, i64) = pool
- .call(move |conn| {
- Ok((
- conn.query_row(
- "SELECT COUNT(*) FROM posts WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- conn.query_row(
- "SELECT COUNT(*) FROM media WHERE owner_user_id = ?",
- [alice],
- |row| row.get(0),
- )?,
- ))
- })
- .await
- .expect("counts");
- assert_eq!(counts, (1, 1));
- assert!(pending_media_journals(&paths).is_empty());
-
- let fixed_path = paths.uploads_images.join("fixed.webp");
- fs::write(&fixed_path, b"image").expect("fixed media file");
- let fixed = fixed_path.to_string_lossy().to_string();
- pool.call(move |conn| {
- conn.execute(
- "UPDATE media SET stored_path = ? WHERE owner_user_id = ?",
- params![fixed, alice],
- )?;
- Ok(())
- })
- .await
- .expect("fix stored path");
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize after fix"),
- 1
- );
- assert!(!user_exists(&pool, alice).await);
- assert!(!fixed_path.exists());
- assert!(pending_media_journals(&paths).is_empty());
- }
-
- #[tokio::test]
- async fn failed_media_removal_retains_journal_until_recovery() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- let blocked_path = paths.uploads_images.join("blocked-media");
- fs::create_dir(&blocked_path).expect("media directory");
- fs::write(blocked_path.join("inner.webp"), b"inner").expect("inner file");
- insert_media_row(&pool, alice, &blocked_path.to_string_lossy()).await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize"),
- 1
- );
- assert!(!user_exists(&pool, alice).await);
- assert_eq!(
- pending_media_journals(&paths).len(),
- 1,
- "a failed removal must keep its journal"
- );
- assert!(blocked_path.exists());
-
- fs::remove_dir_all(&blocked_path).expect("clear blocked directory");
- fs::write(&blocked_path, b"image").expect("regular file");
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover"),
- 1
- );
- assert!(!blocked_path.exists());
- assert!(pending_media_journals(&paths).is_empty());
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover again"),
- 0
- );
- }
-
- #[tokio::test]
- async fn recovery_processes_crash_journal_and_skips_unsafe_lines() {
- let (temp, paths, _pool, _settings, alice, _bob) = fixture().await;
- let real_path = paths.uploads_videos.join("crashed.mp4");
- fs::write(&real_path, b"video").expect("real media file");
- let missing_path = paths.uploads_thumbs.join("missing.webp");
- let outside_path = temp.path().join("outside.webp");
- fs::write(&outside_path, b"outside").expect("outside file");
- let journal_path = paths.tmp_dir.join(format!(
- "{PENDING_MEDIA_DELETION_PREFIX}{alice}-crash{PENDING_MEDIA_DELETION_SUFFIX}"
- ));
- fs::write(
- &journal_path,
- format!(
- "{alice}\n{}\n{}\n{}\n../relative.webp\n",
- real_path.display(),
- missing_path.display(),
- outside_path.display()
- ),
- )
- .expect("crash journal");
-
- let cleanup_removed = paths
- .cleanup_stale_temp_files(std::time::Duration::ZERO)
- .expect("temp cleanup");
- assert_eq!(cleanup_removed, 0);
- assert!(
- journal_path.exists(),
- "runtime temp cleanup must not remove deletion journals"
- );
-
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover"),
- 1
- );
- assert!(!real_path.exists());
- assert!(!missing_path.exists());
- assert!(
- outside_path.exists(),
- "paths outside the upload roots must never be removed"
- );
- assert!(!journal_path.exists());
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover again"),
- 0
- );
- }
-
- #[tokio::test]
- async fn recovery_retains_malformed_journals() {
- let (_temp, paths, _pool, _settings, alice, _bob) = fixture().await;
- let real_path = paths.uploads_images.join("review-target.webp");
- fs::write(&real_path, b"image").expect("media file");
- let journal_path = paths.tmp_dir.join(format!(
- "{PENDING_MEDIA_DELETION_PREFIX}malformed{PENDING_MEDIA_DELETION_SUFFIX}"
- ));
-
- fs::write(&journal_path, "").expect("empty journal");
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover empty"),
- 0
- );
- assert!(journal_path.exists());
-
- fs::write(
- &journal_path,
- format!("not-a-user-id\n{}\n", real_path.display()),
- )
- .expect("garbage journal");
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover garbage"),
- 0
- );
- assert!(journal_path.exists());
- assert!(real_path.exists());
-
- fs::write(&journal_path, format!("{alice}\n{}\n", real_path.display()))
- .expect("fixed journal");
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover fixed"),
- 1
- );
- assert!(!real_path.exists());
- assert!(!journal_path.exists());
- }
-
- #[tokio::test]
- async fn startup_and_periodic_sweeps_are_idempotent() {
- let (_temp, paths, pool, settings, alice, bob) = fixture().await;
- let alice_media = paths.uploads_images.join("restart-sweep.webp");
- fs::write(&alice_media, b"image").expect("alice media file");
- insert_media_row(&pool, alice, &alice_media.to_string_lossy()).await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request alice");
- expire_deletion_deadline(&pool, alice).await;
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("startup finalize"),
- 1
- );
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("startup recovery"),
- 0
- );
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("periodic finalize"),
- 0
- );
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("periodic recovery"),
- 0
- );
- assert!(!user_exists(&pool, alice).await);
- assert!(!alice_media.exists());
-
- let bob_media = paths.uploads_images.join("concurrent-sweep.webp");
- fs::write(&bob_media, b"image").expect("bob media file");
- insert_media_row(&pool, bob, &bob_media.to_string_lossy()).await;
- request_deletion(&pool, &settings, bob)
- .await
- .expect("request bob");
- expire_deletion_deadline(&pool, bob).await;
-
- let (finalized, recovered) = tokio::join!(
- finalize_due_deletions(&pool, &paths),
- recover_pending_media_deletions(&paths)
- );
- assert_eq!(finalized.expect("concurrent finalize"), 1);
- recovered.expect("concurrent recovery");
- assert!(!user_exists(&pool, bob).await);
- assert!(!bob_media.exists());
- assert!(pending_media_journals(&paths).is_empty());
- assert_eq!(
- recover_pending_media_deletions(&paths)
- .await
- .expect("recover after sweep"),
- 0
- );
- }
-
- #[tokio::test]
- async fn zero_grace_deletion_removes_account_and_media_immediately() {
- let (_temp, paths, pool, mut settings, alice, _bob) = fixture().await;
- settings.accounts.deletion_grace_period_days = 0;
- let media_path = paths.uploads_images.join("zero-grace.webp");
- fs::write(&media_path, b"image").expect("media file");
- insert_media_row(&pool, alice, &media_path.to_string_lossy()).await;
-
- let request = request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- assert_eq!(request.requested_at, request.scheduled_at);
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize immediately"),
- 1
- );
- assert!(!user_exists(&pool, alice).await);
- assert!(!media_path.exists());
- assert!(pending_media_journals(&paths).is_empty());
- }
-
- #[tokio::test]
- async fn zero_grace_cancellation_before_finalization_wins() {
- let (_temp, paths, pool, mut settings, alice, _bob) = fixture().await;
- settings.accounts.deletion_grace_period_days = 0;
- let media_path = paths.uploads_images.join("zero-grace-cancel.webp");
- fs::write(&media_path, b"image").expect("media file");
- insert_media_row(&pool, alice, &media_path.to_string_lossy()).await;
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
-
- assert!(cancel_deletion(&pool, alice).await.expect("cancel"));
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize after cancellation"),
- 0
- );
- assert!(user_exists(&pool, alice).await);
- assert!(media_path.exists());
- assert!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .is_none()
- );
- }
-
- #[tokio::test]
- async fn finalization_removes_every_session_for_the_account() {
- let (_temp, paths, pool, settings, alice, _bob) = fixture().await;
- let first = auth::create_session(&pool, alice)
- .await
- .expect("first session");
- let second = auth::create_session(&pool, alice)
- .await
- .expect("second session");
- request_deletion(&pool, &settings, alice)
- .await
- .expect("request");
- expire_deletion_deadline(&pool, alice).await;
-
- assert_eq!(
- finalize_due_deletions(&pool, &paths)
- .await
- .expect("finalize"),
- 1
- );
-
- let sessions: i64 = pool
- .call(move |conn| {
- Ok(conn.query_row(
- "SELECT COUNT(*) FROM sessions WHERE user_id = ?",
- [alice],
- |row| row.get(0),
- )?)
- })
- .await
- .expect("sessions");
- assert_eq!(sessions, 0);
- let token_hashes = vec![
- auth::hash_token(&first.token),
- auth::hash_token(&second.token),
- ];
- let resolved: i64 = pool
- .call(move |conn| {
- let mut resolved = 0;
- for token_hash in &token_hashes {
- resolved += conn.query_row(
- "SELECT COUNT(*) FROM sessions WHERE token_hash = ?",
- [token_hash.as_str()],
- |row| row.get::<_, i64>(0),
- )?;
- }
- Ok(resolved)
- })
- .await
- .expect("token lookups");
- assert_eq!(
- resolved, 0,
- "session tokens must not resolve after deletion"
- );
- }
-
- #[tokio::test]
- async fn concurrent_request_deletion_is_idempotent() {
- let (_temp, _paths, pool, settings, alice, _bob) = fixture().await;
- let (first, second) = tokio::join!(
- request_deletion(&pool, &settings, alice),
- request_deletion(&pool, &settings, alice)
- );
- let first = first.expect("first request");
- let second = second.expect("second request");
- assert_eq!(first, second);
-
- let stored = deletion_status(&pool, alice)
- .await
- .expect("status")
- .expect("pending");
- assert_eq!(
- stored, first,
- "concurrent requests must not move the deadline"
- );
-
- let repeated = request_deletion(&pool, &settings, alice)
- .await
- .expect("repeated request");
- assert_eq!(repeated, first);
- assert_eq!(
- deletion_status(&pool, alice)
- .await
- .expect("status")
- .expect("still pending"),
- first
- );
- }
-}
diff --git a/src/admin.rs b/src/admin.rs
index 831a234..87b6b80 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,13 @@ 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;
-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 +25,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 +53,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 +86,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 +129,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 +140,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()))
-}
-
-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");
+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>,
+}
+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(())
}
@@ -1500,328 +810,3 @@ pub async fn media_jobs_report(pool: &SqlitePool) -> anyhow::Result (tempfile::TempDir, SqlitePool, Settings) {
- let temp = tempdir().expect("temp dir");
- let pool = crate::db::connect(&temp.path().join("test.sqlite3"))
- .await
- .expect("connect");
- crate::db::migrate(&pool).await.expect("migrate");
- (temp, pool, Settings::default())
- }
-
- async fn register(pool: &SqlitePool, settings: &Settings, username: &str) -> i64 {
- auth::register_user(pool, settings, username, "very secure password", false)
- .await
- .expect("register user")
- }
-
- async fn create_post(pool: &SqlitePool, settings: &Settings, user_id: i64, text: &str) -> i64 {
- crate::social::create_post(pool, settings, Some(user_id), text, None, &[])
- .await
- .expect("create post")
- }
-
- fn usernames(rows: &[AdminUserInvestigation]) -> Vec<&str> {
- rows.iter().map(|row| row.username.as_str()).collect()
- }
-
- 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(),
- }
- }
-
- #[test]
- fn admin_post_search_parses_keywords_quotes_and_malformed_quotes() {
- assert_eq!(
- parse_admin_post_search("rust admin"),
- ParsedAdminPostSearch {
- mode: Some(AdminPostSearchMode::Keywords(vec![
- "rust".to_owned(),
- "admin".to_owned()
- ])),
- malformed_quotes: false,
- }
- );
- assert_eq!(
- parse_admin_post_search(r#""hello world""#),
- ParsedAdminPostSearch {
- mode: Some(AdminPostSearchMode::ExactPhrase("hello world".to_owned())),
- malformed_quotes: false,
- }
- );
- assert_eq!(
- parse_admin_post_search(r#""hello world"#),
- ParsedAdminPostSearch {
- mode: Some(AdminPostSearchMode::Keywords(vec![
- "hello".to_owned(),
- "world".to_owned()
- ])),
- malformed_quotes: true,
- }
- );
- }
-
- #[tokio::test]
- async fn admin_username_search_matches_username_handle_and_display_name() {
- let (_temp, pool, settings) = test_pool().await;
- let alice = register(&pool, &settings, "alice").await;
- register(&pool, &settings, "bob").await;
- pool.call(move |conn| {
- conn.execute(
- "UPDATE users SET display_name = 'Ada Admin' WHERE id = ?",
- [alice],
- )?;
- Ok(())
- })
- .await
- .expect("display name");
-
- let by_handle = users(&pool, AdminUserSearch::new("@ali", ""))
- .await
- .expect("search by handle");
- let by_display = users(&pool, AdminUserSearch::new("ada", ""))
- .await
- .expect("search by display");
-
- assert_eq!(usernames(&by_handle), vec!["alice"]);
- assert!(by_handle[0].matched_name);
- assert_eq!(usernames(&by_display), vec!["alice"]);
- }
-
- #[tokio::test]
- async fn admin_plain_post_keyword_search_matches_accounts_with_all_terms() {
- let (_temp, pool, settings) = test_pool().await;
- let alice = register(&pool, &settings, "alice").await;
- let bob = register(&pool, &settings, "bob").await;
- create_post(&pool, &settings, alice, "Rust admin investigation notes").await;
- create_post(&pool, &settings, bob, "Rust release notes").await;
-
- let rows = users(&pool, AdminUserSearch::new("", "rust investigation"))
- .await
- .expect("post search");
-
- assert_eq!(usernames(&rows), vec!["alice"]);
- assert_eq!(rows[0].matching_post_count, 1);
- assert_eq!(
- rows[0].post_match_preview.as_deref(),
- Some("Rust admin investigation notes")
- );
- }
-
- #[tokio::test]
- async fn admin_quoted_post_search_matches_exact_substring_phrase() {
- let (_temp, pool, settings) = test_pool().await;
- let alice = register(&pool, &settings, "alice").await;
- let bob = register(&pool, &settings, "bob").await;
- create_post(&pool, &settings, alice, "hello world from alice").await;
- create_post(&pool, &settings, bob, "hello careful world from bob").await;
-
- let rows = users(&pool, AdminUserSearch::new("", r#""hello world""#))
- .await
- .expect("phrase search");
-
- assert_eq!(usernames(&rows), vec!["alice"]);
- }
-
- #[tokio::test]
- async fn admin_post_search_escapes_like_special_characters() {
- let (_temp, pool, settings) = test_pool().await;
- let alice = register(&pool, &settings, "alice").await;
- let bob = register(&pool, &settings, "bob").await;
- create_post(&pool, &settings, alice, "token 100%_safe").await;
- create_post(&pool, &settings, bob, "token 1000 safe").await;
-
- let rows = users(&pool, AdminUserSearch::new("", "100%_safe"))
- .await
- .expect("escaped search");
-
- assert_eq!(usernames(&rows), vec!["alice"]);
- }
-
- #[test]
- fn deep_settings_form_parsing_accepts_valid_values() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.site_name = "Custom Site".to_owned();
- form.max_bio_len = "300".to_owned();
- form.allow_profile_pictures = "false".to_owned();
- form.nsfw_blur_enabled = "false".to_owned();
-
- let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
-
- assert_eq!(parsed.site_name, "Custom Site");
- assert_eq!(parsed.max_bio_len, 300);
- assert!(!parsed.allow_profile_pictures);
- assert!(!parsed.nsfw_blur_enabled);
- }
-
- #[test]
- fn deep_settings_form_parsing_rejects_invalid_numbers() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.max_bio_len.clear();
- assert!(parse_deep_settings_form(&form, &settings).is_err());
-
- let mut form = form_from_settings(&settings);
- form.max_bio_len = "nope".to_owned();
- assert!(parse_deep_settings_form(&form, &settings).is_err());
-
- let mut form = form_from_settings(&settings);
- form.max_bio_len = "-1".to_owned();
- assert!(parse_deep_settings_form(&form, &settings).is_err());
- }
-
- #[test]
- fn deep_settings_form_parsing_rejects_invalid_boolean_values() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.allow_likes = "yes".to_owned();
-
- let err = parse_deep_settings_form(&form, &settings).expect_err("invalid bool");
-
- assert!(
- err.to_string()
- .contains("Allow likes must be true or false")
- );
- }
-
- #[test]
- fn unchanged_deep_settings_form_has_no_diff() {
- let settings = Settings::default();
- 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());
- }
-
- #[test]
- fn changed_deep_settings_diff_uses_friendly_labels_and_units() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.max_bio_len = "300".to_owned();
- form.allow_profile_pictures = "false".to_owned();
- form.nsfw_blur_enabled = "false".to_owned();
- let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
-
- let diff = diff_deep_settings(&settings, &parsed);
-
- assert_eq!(
- diff,
- vec![
- DeepSettingsChange {
- label: "Maximum bio length",
- old_value: "240 characters".to_owned(),
- new_value: "300 characters".to_owned(),
- },
- DeepSettingsChange {
- label: "Allow profile pictures",
- old_value: "true".to_owned(),
- new_value: "false".to_owned(),
- },
- DeepSettingsChange {
- label: "Blur NSFW media",
- old_value: "true".to_owned(),
- new_value: "false".to_owned(),
- },
- ]
- );
- }
-
- #[test]
- fn deep_settings_media_mb_values_convert_to_bytes() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.max_image_size_mb = "8".to_owned();
- form.max_video_size_mb = "50".to_owned();
- let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
- let updated = parsed.apply_to(&settings);
-
- assert_eq!(updated.media.max_image_size, 8 * MIB);
- assert_eq!(updated.media.max_video_size, 50 * MIB);
- }
-
- #[test]
- fn deep_settings_accepts_operator_chosen_minimum_password_length() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.min_password_length = "5".to_owned();
-
- let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
- let updated = parsed.apply_to(&settings);
-
- assert_eq!(updated.accounts.min_password_length, 5);
- }
-
- #[test]
- fn deep_settings_accepts_zero_post_edit_window_as_disabled() {
- let settings = Settings::default();
- let mut form = form_from_settings(&settings);
- form.post_edit_window_seconds = "0".to_owned();
-
- let parsed = parse_deep_settings_form(&form, &settings).expect("valid form");
- let updated = parsed.apply_to(&settings);
-
- assert_eq!(updated.posts.post_edit_window_seconds, 0);
- }
-
- #[test]
- fn deep_settings_writeback_preserves_unrelated_values_and_comments() {
- let temp = tempdir().expect("temp dir");
- let path = temp.path().join("settings.toml");
- crate::config::write_default_if_missing(&path).expect("default settings");
- let mut settings = Settings::load(&path).expect("load settings");
- settings.site.name = "Written Site".to_owned();
- settings.accounts.max_bio_len = 300;
- settings.media.nsfw_blur_enabled = false;
- settings.media.max_image_size = 8 * MIB;
-
- write_deep_settings(&path, &settings).expect("write settings");
-
- let raw = fs::read_to_string(&path).expect("settings raw");
- let parsed = Settings::load(&path).expect("reload settings");
- assert!(raw.contains("# RustPost settings"));
- assert_eq!(parsed.site.name, "Written Site");
- assert_eq!(parsed.accounts.max_bio_len, 300);
- assert!(!parsed.media.nsfw_blur_enabled);
- assert_eq!(parsed.media.max_image_size, 8 * MIB);
- assert_eq!(parsed.server.port, Settings::default().server.port);
- assert_eq!(
- parsed.media.ffmpeg_path,
- Settings::default().media.ffmpeg_path
- );
- }
-}
diff --git a/src/auth.rs b/src/auth.rs
index e8fcf3a..496d9ea 100644
--- a/src/auth.rs
+++ b/src/auth.rs
@@ -1,5 +1,5 @@
use anyhow::Context as _;
-use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString};
+use argon2::password_hash::{PasswordHasher as _, PasswordVerifier as _, phc::PasswordHash};
use argon2::{Algorithm, Argon2, Params, Version};
use axum::http::HeaderMap;
use chrono::{Duration, Utc};
@@ -164,7 +164,7 @@ pub async fn login(
pub async fn create_session(pool: &SqlitePool, user_id: i64) -> anyhow::Result {
let token = secure_token();
- let csrf_token = secure_token();
+ let csrf_token = session_csrf_token(&token);
let expires_at = Utc::now() + Duration::days(30);
let token_hash = hash_token(&token);
let csrf_hash = hash_token(&csrf_token);
@@ -233,16 +233,34 @@ pub async fn verify_user_password(
/// Argon2id hashing is intentionally CPU and memory heavy; run it on the
/// blocking pool so it cannot stall the async runtime under load.
+#[derive(Debug, thiserror::Error)]
+#[error("password processing is busy; try again shortly")]
+pub(crate) struct PasswordWorkBusy;
+
+static PASSWORD_WORK: tokio::sync::Semaphore = tokio::sync::Semaphore::const_new(2);
+
pub async fn hash_password_async(password: String) -> anyhow::Result {
- tokio::task::spawn_blocking(move || hash_password(&password))
+ let permit = tokio::time::timeout(std::time::Duration::from_secs(30), PASSWORD_WORK.acquire())
.await
- .context("password hashing task failed")?
+ .map_err(|_| PasswordWorkBusy)??;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ hash_password(&password)
+ })
+ .await
+ .context("password hashing task failed")?
}
async fn verify_password_async(password: String, encoded_hash: String) -> anyhow::Result {
- tokio::task::spawn_blocking(move || verify_password(&password, &encoded_hash))
+ let permit = tokio::time::timeout(std::time::Duration::from_secs(30), PASSWORD_WORK.acquire())
.await
- .context("password verification task failed")?
+ .map_err(|_| PasswordWorkBusy)??;
+ tokio::task::spawn_blocking(move || {
+ let _permit = permit;
+ verify_password(&password, &encoded_hash)
+ })
+ .await
+ .context("password verification task failed")?
}
pub async fn change_password(
@@ -339,7 +357,7 @@ pub async fn csrf_hashes_for_cookie(
let token_hash = hash_token(token);
pool.call(move |conn| {
conn.query_row(
- "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL AND datetime(expires_at) > CURRENT_TIMESTAMP",
+ "SELECT substr(s.csrf_token_hash, 1, 128), substr(s.previous_csrf_token_hash, 1, 4096) FROM sessions s JOIN users u ON u.id = s.user_id WHERE s.token_hash = ? AND s.revoked_at IS NULL AND datetime(s.expires_at) > CURRENT_TIMESTAMP AND u.is_deleted = 0",
[token_hash],
|row| {
let current = row.get::<_, String>(0)?;
@@ -351,6 +369,7 @@ pub async fn csrf_hashes_for_cookie(
previous
.lines()
.filter(|hash| !hash.is_empty())
+ .take(31)
.map(ToOwned::to_owned),
);
}
@@ -363,13 +382,43 @@ pub async fn csrf_hashes_for_cookie(
.await
}
+/// Parse all Cookie fields (including HTTP/2 split fields), rejecting duplicate
+/// session names rather than letting browser/proxy ordering select an identity.
+pub(crate) fn checked_session_cookie(headers: &HeaderMap) -> anyhow::Result