diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 3dc7c15..f2f801b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,8 +19,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: components: rustfmt - name: Check formatting @@ -34,8 +34,8 @@ jobs: uses: actions/checkout@v4 - name: Install Linux build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: components: clippy - name: Cache Cargo @@ -53,8 +53,8 @@ jobs: uses: actions/checkout@v4 - name: Install Linux build tools run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: components: clippy - name: Cache Cargo @@ -85,8 +85,8 @@ jobs: - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 - name: Cache Cargo uses: Swatinem/rust-cache@v2 with: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index ec2b9aa..6bad4b5 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -37,8 +37,8 @@ jobs: - name: Install Linux build tools if: runner.os == 'Linux' run: sudo apt-get update && sudo apt-get install -y --no-install-recommends build-essential pkg-config - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: targets: ${{ matrix.target }} - name: Cache Cargo @@ -58,7 +58,7 @@ jobs: 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 - shasum -a 256 "dist/${{ matrix.archive }}" > "dist/${{ matrix.archive }}.sha256" + (cd dist && shasum -a 256 "${{ matrix.archive }}" > "${{ matrix.archive }}.sha256") - name: Upload release assets uses: softprops/action-gh-release@v2 with: @@ -72,8 +72,8 @@ jobs: steps: - name: Checkout uses: actions/checkout@v4 - - name: Install Rust 1.90 - uses: dtolnay/rust-toolchain@1.90.0 + - name: Install Rust 1.91 + uses: dtolnay/rust-toolchain@1.91.0 with: targets: x86_64-pc-windows-msvc - name: Cache Cargo diff --git a/.gitignore b/.gitignore index b5a806e..2d78a84 100644 --- a/.gitignore +++ b/.gitignore @@ -73,6 +73,9 @@ uploads/**/* # macOS .DS_Store **/.DS_Store + +# Local Codex guidance (intentionally not published) +/rules.md /tests !/tests/ !/tests/playwright/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 6febc85..63edd7c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,15 +1,59 @@ # Changelog -## Unreleased - First-Release Database Baseline +## v1.0.0 - First Release + +### Instance and social controls +- Protected accounts: users can require approval before someone follows them. Requests are pending until approved, never count as followers, can be rejected or cancelled, and are cleared by blocks. +- Instance announcements: administrators can publish, disable, or clear an announcement rendered next to the site name in the top bar. +- Maintenance mode: administrators can disable registration and post creation (including replies, edits, quotes, reposts, and account imports) while the site stays readable; administrators keep posting access to verify the instance, and the block policy is a single table-driven rule set. + +### Account lifecycle +- Account export/import: versioned `.tar.gz` archives with posts, profile data, media bytes, media list, outgoing follows, and settings; imports are validated, staged, remapped, and protected by repeat-import tracking. +- Archive hardening: compressed upload/export and decompressed-content limits are independent and configurable, structural limits are enforced incrementally while streaming, media bytes must match their declared size/hash/type, and failed imports leave no partial state. +- Admin forced password reset: a flagged account is restricted to the password-change and logout flows until the password changes, including on existing sessions. +- Admin forced logout: every active session for one account can be revoked without affecting other accounts. +- Account deletion grace period: deletion requests store a deadline (default 30 days, configurable), can be cancelled, block publishing while pending, and are finalized idempotently from persisted state with a durable media-cleanup journal. +- Username changes and history: handles are validated and changed atomically, previous handles stay reserved, same-owner reclamation is allowed, and old profile URLs render a history or released-handle page instead of silently resolving to a different person. + +### Database +- Schema version `4` adds account lifecycle columns, `follow_requests`, `username_history`, `instance_settings`, and `account_imports` with a forward migration from versions `1`–`3` and alpha adoption. + +### Runtime and Dependency Baseline +- Raised the minimum supported Rust version to `1.91` and updated local, CI, and release-build documentation accordingly. +- Upgraded the embedded Arti and Tor crate family to `0.46.0`, including the onion-stream API migration from the earlier pre-release baseline. +- Updated direct dependencies to their latest compatible releases, including `base64 0.23`, `tower-http 0.7`, `infer 0.22`, and `ureq 3.4`. +- Kept `rusqlite` at `0.36` so Cargo resolves a single compatible `libsqlite3-sys` version with Arti `0.46.0`. + +### Release Blockers Fixed +- Standardized the release artifact, install docs, CI, and operator commands on the single `rustpost-cli` binary. +- Bumped the crate version to `1.0.0` and refreshed `Cargo.lock`. +- Updated first-run command hints to use the canonical `rustpost-cli` binary. +- Removed the nonfunctional standalone `print-onion-address` command; active onion addresses are reported by the running service. +- Fixed login so existing stored passwords continue to work after an operator raises the configured minimum password length. +- Rejected unsafe profile website URL schemes server-side and stopped rendering unsafe legacy profile website values as links. +- Enforced configured per-post image and video attachment limits and raised the multipart body limit to cover valid configured media mixes. +- Hardened invalid upload cleanup with a cancellation-safe staged-file guard so rejected malformed, oversized, or staging-error uploads do not leave orphaned files under `tmp/uploads`. +- Fixed restored media path remapping for Windows paths so backups remain usable after restoring into a different data directory. +- Return a signed-out response when account finalization races settings page loading, instead of surfacing a server error. +- Corrected generated settings copy for username, display name, and bio limits from bytes to characters. + +### Release Validation +- Added regression coverage for malformed and oversized rejected upload cleanup, guard-drop cleanup, and successful durable-media handoff. +- Completed a focused invalid-upload cleanup probe and three live disposable stress reruns across public browsing, auth, social, media, admin/diagnostics, and no-JS/Tor-like browser behavior with no pending staged uploads. +- Updated the ignored local Playwright release harness so it no longer configures the rejected `tor.display_onion_address`; synthetic local Tor-header assertions were removed while opt-in real-onion coverage remains. +- Documented the accepted v1.0.0 `cargo audit` exception for transitive Arti `rsa 0.9.10` / `RUSTSEC-2023-0071`, for which no fixed upgrade is currently available. +- Recorded unmaintained transitive Arti dependencies as upstream maintenance caveats rather than RustPost application vulnerabilities. +- Kept Playwright files and generated artifacts ignored and out of the repository. +- Audited the release build, clean temporary-runtime first-run flow, restart persistence, backup/restore behavior, runtime permissions, and operator documentation before tagging. ### Database Lineage -- Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `1`. +- Squashed the pre-release internal migration chain into a clean first-release SQLite schema baseline at database schema version `4`. - Fresh databases are now initialized directly from the baseline instead of replaying alpha development migrations. -- Existing current alpha databases that structurally match the baseline are marked as baseline version `1` without destructive changes or data loss. +- Existing compatible alpha and release-candidate databases are migrated to schema version `4` without destructive changes or data loss. - Incomplete, unknown, or structurally unsafe pre-release databases now fail closed with administrator guidance to back up/export/recreate/restore instead of attempting blind migration. - Added stricter schema diagnostics for required tables, columns, indexes, and triggers so startup, backups, restores, and admin health can report incompatible or corrupt database structure clearly. - `check` now reports database schema status without creating or migrating the database during diagnostics. -- Future post-release database changes should use normal forward migrations after baseline version `1`; released migration history must not be squashed or rewritten after the first public release. +- Future post-release database changes should use normal forward migrations after baseline version `4`; released migration history must not be squashed or rewritten after the first public release. ## v0.1.6 - Pinned Profiles diff --git a/Cargo.lock b/Cargo.lock index 0682771..d7eb54f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -10,21 +10,21 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "aes" -version = "0.8.4" +version = "0.9.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +checksum = "35f0f96ce78e38c3dc6d8948aa8163d06385be74000f3c7a95bf1eef35d3ea32" dependencies = [ - "cfg-if", - "cipher", - "cpufeatures 0.2.17", + "cipher 0.5.2", + "cpubits", + "cpufeatures 0.3.1", "zeroize", ] [[package]] name = "aho-corasick" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" dependencies = [ "memchr", ] @@ -77,9 +77,9 @@ dependencies = [ [[package]] name = "android_system_properties" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" dependencies = [ "libc", ] @@ -136,9 +136,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "argon2" @@ -154,15 +154,15 @@ dependencies = [ [[package]] name = "arrayvec" -version = "0.7.6" +version = "0.7.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" +checksum = "d3fb67a6e08acf24fdeccbac2cb6ac4305825bd1f117462e0e6f2f193345ad56" [[package]] name = "arti-client" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16739ee6ad4ee11073a8d12c34a911ea72977c5ce4abbb21a1f7cf5955f22a01" +checksum = "aafcbc506657d886b27ef2d8e06a99edd9014b28130c798a3013935b46983097" dependencies = [ "async-trait", "cfg-if", @@ -178,11 +178,11 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.9.4", + "rand 0.10.3", "safelog", "serde", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -230,7 +230,7 @@ dependencies = [ "nom", "num-traits", "rusticata-macros", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -241,8 +241,8 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 2.0.119", + "synstructure 0.13.2", ] [[package]] @@ -253,7 +253,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -264,9 +264,9 @@ checksum = "9b34d609dfbaf33d6889b2b7106d3ca345eacad44200913df5ba02bfd31d2ba9" [[package]] name = "async-compression" -version = "0.4.42" +version = "0.4.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +checksum = "fb61aea1a7def73ee7c350a184f0e70b32c182344e2e75bf70c9b621b83417fd" dependencies = [ "compression-codecs", "compression-core", @@ -276,13 +276,13 @@ dependencies = [ [[package]] name = "async-trait" -version = "0.1.89" +version = "0.1.92" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +checksum = "82f6aeea286b8eb4dd3431a1be1b59d290ace00f5bfd8e2a159bc2a05e2c1667" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -407,9 +407,9 @@ checksum = "9e1b586273c5702936fe7b7d6896644d8be71e6314cfe09d3167c95f712589e8" [[package]] name = "base64" -version = "0.22.1" +version = "0.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" [[package]] name = "base64ct" @@ -435,15 +435,15 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.12.1" +version = "2.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d7ced0ae9557296835c32bf1b1e02b44c746701f898460fb000d7eaa84f00a" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" [[package]] name = "bitvec" -version = "1.0.1" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1bc2832c24239b0141d5674bb9174f9d68a8b5b3f2753311927c172ca46f7e9c" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" dependencies = [ "funty", "radium", @@ -468,7 +468,7 @@ checksum = "e0b121a9fe0df916e362fb3271088d071159cdf11db0e4182d02152850756eff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -480,6 +480,16 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-buffer" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa" +dependencies = [ + "hybrid-array", + "zeroize", +] + [[package]] name = "bs58" version = "0.5.1" @@ -491,13 +501,13 @@ dependencies = [ [[package]] name = "bstr" -version = "1.12.1" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "63044e1ae8e69f3b5a92c736ca6269b8d12fa7efe39bf34ddb06d102cf0e2cab" +checksum = "6bb31b46c14244e20ee9984b11bf5c992b91fb6939fea616e3512c8baecdbe5f" dependencies = [ "memchr", "regex-automata", - "serde", + "serde_core", ] [[package]] @@ -514,9 +524,9 @@ checksum = "64fa3c856b712db6612c019f14756e64e4bcea13337a6b33b696333a9eaa2d06" [[package]] name = "bytemuck" -version = "1.25.0" +version = "1.25.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8efb64bd706a16a1bdde310ae86b351e4d21550d98d056f22f8a7f7a2183fec" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" [[package]] name = "byteorder" @@ -526,9 +536,9 @@ checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" [[package]] name = "bytes" -version = "1.11.1" +version = "1.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" [[package]] name = "captcha" @@ -539,21 +549,21 @@ dependencies = [ "base64 0.13.1", "image", "lodepng", - "rand 0.9.4", + "rand 0.9.5", "serde_json", ] [[package]] name = "caret" -version = "0.9.0" +version = "0.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" +checksum = "23a6f83e57018d15e7615a6f8e5e2c6b6b0226e24a1c562cd705f855a2bc751a" [[package]] name = "cc" -version = "1.2.63" +version = "1.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" +checksum = "f360145194ee8e21db5ee7f3fcd4fe52210864c75c985dae33218202c8bbe040" dependencies = [ "find-msvc-tools", "shlex", @@ -561,20 +571,31 @@ dependencies = [ [[package]] name = "cfb" -version = "0.7.3" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +checksum = "a347dcabdae9c31b0825fd6a8bed285ec9c2acb89c47827126d52fa4f59cece3" dependencies = [ - "byteorder", "fnv", "uuid", + "web-time", ] [[package]] name = "cfg-if" -version = "1.0.4" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chacha20" +version = "0.10.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] [[package]] name = "chrono" @@ -597,15 +618,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" dependencies = [ "crypto-common 0.1.7", - "inout", + "inout 0.1.4", +] + +[[package]] +name = "cipher" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8cf2a2c93cd704877c0858356ed03480ff301ee950b43f1cbe4573b088bfa6c" +dependencies = [ + "block-buffer 0.12.1", + "crypto-common 0.2.2", + "inout 0.2.2", "zeroize", ] [[package]] name = "clap" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ddb117e43bbf7dacf0a4190fef4d345b9bad68dfc649cb349e7d17d28428e51" +checksum = "aa8876b300ab35ba921adea3dfd70157a46249b33f95c9084ae5709785478946" dependencies = [ "clap_builder", "clap_derive", @@ -613,9 +645,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.6.0" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" +checksum = "ec0797fb7aeb1406c84efac526901f7ec3ead2124f946b494e72879d4b54704d" dependencies = [ "anstream", "anstyle", @@ -625,27 +657,33 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.6.1" +version = "4.6.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f2ce8604710f6733aa641a2b3731eaa1e8b3d9973d5e3565da11800813f997a9" +checksum = "f9c751b79415d4e559e3d1fcf128e09e720eb673a06d26cf6f392d37d75b66e0" dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "clap_lex" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" +checksum = "1c133bc6a41be0d194c306b5506d15e6feeea7b1d6604bd3f8310dfb2ca96486" + +[[package]] +name = "cmov" +version = "0.5.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" [[package]] name = "coarsetime" -version = "0.1.37" +version = "0.1.38" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e58eb270476aa4fc7843849f8a35063e8743b4dbcdf6dd0f8ea0886980c204c2" +checksum = "eae63702c5627c75addbfb1ea9d1b3842205a6ffd43e4cce884a2a9eb1828fd7" dependencies = [ "libc", "wasix", @@ -666,9 +704,9 @@ checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compression-codecs" -version = "0.4.38" +version = "0.4.43" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +checksum = "bef16c47ba2797aa6a909cc37d39911f3a6743811fe7408ac0b0cc0276b656e9" dependencies = [ "compression-core", "flate2", @@ -676,18 +714,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.32" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" - -[[package]] -name = "concurrent-queue" -version = "2.5.0" +version = "0.4.33" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" -dependencies = [ - "crossbeam-utils", -] +checksum = "6e8ccc4ea9f6acc32d102c0f6d471d11d913ad15f20c04de743374861fa1d414" [[package]] name = "const-oid" @@ -725,6 +754,18 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + +[[package]] +name = "cpubits" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15b85f9c39137c3a891689859392b1bd49812121d0d61c9caf00d46ed5ce06ae" + [[package]] name = "cpufeatures" version = "0.2.17" @@ -736,36 +777,36 @@ dependencies = [ [[package]] name = "cpufeatures" -version = "0.3.0" +version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" dependencies = [ "libc", ] [[package]] name = "crc32fast" -version = "1.5.0" +version = "1.5.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" dependencies = [ "cfg-if", ] [[package]] name = "crossbeam-queue" -version = "0.3.12" +version = "0.3.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f58bbc28f91df819d0aa2a2c00cd19754769c2fad90579b3592b1c9ba7a3115" +checksum = "03e8bd762f7479489c70ed6c768ddca99d7296857de437a68dcb2a94365b3fae" dependencies = [ "crossbeam-utils", ] [[package]] name = "crossbeam-utils" -version = "0.8.21" +version = "0.8.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" +checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" [[package]] name = "crypto-bigint" @@ -800,11 +841,20 @@ dependencies = [ [[package]] name = "ctr" -version = "0.9.2" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baaca1c4b237092596f64d571e9db6ce4109c4ef9742e27590f1709594461f21" +dependencies = [ + "cipher 0.5.2", +] + +[[package]] +name = "ctutils" +version = "0.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0369ee1ad671834580515889b80f2ea915f23b8be8d0daa4bbaf2ac5c7590835" +checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e" dependencies = [ - "cipher", + "cmov", ] [[package]] @@ -831,7 +881,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -856,12 +906,12 @@ dependencies = [ [[package]] name = "darling" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "25ae13da2f202d56bd7f91c25fba009e7717a1e4a1cc98a76d844b65ae912e9d" +checksum = "ed17f5901b6630b993ca003def43f2f8ef4014fc13b047b57aad617ff32bc2ec" dependencies = [ - "darling_core 0.23.0", - "darling_macro 0.23.0", + "darling_core 0.24.1", + "darling_macro 0.24.1", ] [[package]] @@ -888,20 +938,20 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_core" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9865a50f7c335f53564bb694ef660825eb8610e0a53d3e11bf1b0d3df31e03b0" +checksum = "6837e2cf7485aaae18f86181d2f0e9a7ed297a025e220aeabf63fdebd3a2ddff" dependencies = [ "ident_case", "proc-macro2", "quote", "strsim 0.11.1", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -923,25 +973,56 @@ checksum = "d38308df82d1080de0afee5d069fa14b0326a88c14f15c5ccda35b4a6c414c81" dependencies = [ "darling_core 0.21.3", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "darling_macro" -version = "0.23.0" +version = "0.24.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac3984ec7bd6cfa798e62b4a642426a5be0e68f9401cfc2a01e3fa9ea2fcdb8d" +checksum = "2ac7135c3ef02b2f7833bbeb1be5ba7f966dcde8a87c6b87f65a778d71a02785" dependencies = [ - "darling_core 0.23.0", + "darling_core 0.24.1", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "data-encoding" -version = "2.11.0" +version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4ae5f15dda3c708c0ade84bfee31ccab44a3da4f88015ed22f63732abe300c8" +checksum = "4583a4551df46e2792f82ceeac45e850d2e2d5debba0b91f102385cda5b11f06" + +[[package]] +name = "defmt" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e2953bfe4f93bbd20cc71198842756f77d161884c99ebbabc41d80231ded88d1" +dependencies = [ + "bitflags 1.3.2", + "defmt-macros", +] + +[[package]] +name = "defmt-macros" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bad9c72e7ca2137e0dc3813245a0d282fd6daad32fd800af018306a9169b5fe8" +dependencies = [ + "defmt-parser", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "defmt-parser" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10d60334b3b2e7c9d91ef8150abfb6fa4c1c39ebbcf4a81c2e346aad939fee3e" +dependencies = [ + "thiserror 2.0.21", +] [[package]] name = "der" @@ -974,15 +1055,14 @@ version = "0.5.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7cd812cc2bc1d69d4764bd80df88b4317eaef9e773c75226407d9bc0876b211c" dependencies = [ - "powerfmt", "serde_core", ] [[package]] name = "derive-deftly" -version = "1.11.2" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "219c002a60a3baf5d3fa28db4e1c98733aa2bc784aa63b83c011a72ae9335fb2" +checksum = "98e02cd118058400797c74550dca132b57cf2e6af0e9ea0672a83eb186fd655e" dependencies = [ "derive-deftly-macros", "heck", @@ -990,19 +1070,19 @@ dependencies = [ [[package]] name = "derive-deftly-macros" -version = "1.11.2" +version = "1.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5850ec9ad2d9ba0aa33fb22c0b0ef4d91e524566be497ac2a6e40b847e67bb" +checksum = "ba91f619216e76a5eb2515783f7ec2e271938b51aadac592f4930517f4626863" dependencies = [ "heck", - "indexmap 2.14.0", + "indexmap 2.14.2", "itertools", "proc-macro-crate", "proc-macro2", "quote", "sha3 0.12.0", "strum", - "syn 2.0.117", + "syn 3.0.6", "unicode-ident", "void", ] @@ -1057,7 +1137,7 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.117", + "syn 2.0.119", "unicode-xid", ] @@ -1067,7 +1147,7 @@ version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ - "block-buffer", + "block-buffer 0.10.4", "const-oid 0.9.6", "crypto-common 0.1.7", "subtle", @@ -1079,6 +1159,7 @@ version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2" dependencies = [ + "block-buffer 0.12.1", "const-oid 0.10.2", "crypto-common 0.2.2", ] @@ -1115,13 +1196,13 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.6" +version = "0.2.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" +checksum = "c6232dd377dcc64799954cbd3a9bb882e9cdc1308ccd87b1c098f1fb2eaf82a8" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1171,7 +1252,7 @@ dependencies = [ "merlin", "rand_core 0.6.4", "serde", - "sha2", + "sha2 0.10.9", "subtle", "zeroize", ] @@ -1190,9 +1271,9 @@ dependencies = [ [[package]] name = "either" -version = "1.16.0" +version = "1.18.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" +checksum = "252afb9ae5eaa683babdc6a068b3f5726eb19e05070c731f9b2a23a7c3e8ed34" [[package]] name = "elliptic-curve" @@ -1215,11 +1296,16 @@ dependencies = [ [[package]] name = "encoding_rs" -version = "0.8.35" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" dependencies = [ "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", ] [[package]] @@ -1232,7 +1318,7 @@ dependencies = [ "num-traits", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1244,14 +1330,14 @@ dependencies = [ "once_cell", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "enumset" -version = "1.1.13" +version = "1.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "839c4174b41e75c8f7306110b2c51996a293b8d1d850edd529011841d9fede7d" +checksum = "ccc5801fd11762e24d1e420d01d2ac518f2a2ca4329d4fbb6639f2412b6204e0" dependencies = [ "enumset_derive", ] @@ -1265,7 +1351,7 @@ dependencies = [ "darling 0.21.3", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1286,15 +1372,25 @@ dependencies = [ [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] +[[package]] +name = "extend" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "311a6d2f1f9d60bff73d2c78a0af97ed27f79672f15c238192a5bbb64db56d00" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "fallible-iterator" version = "0.3.0" @@ -1309,9 +1405,9 @@ checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" [[package]] name = "fastrand" -version = "2.4.1" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" [[package]] name = "fdeflate" @@ -1363,18 +1459,18 @@ dependencies = [ [[package]] name = "find-msvc-tools" -version = "0.1.9" +version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" [[package]] name = "flate2" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" dependencies = [ "crc32fast", - "miniz_oxide", + "miniz_oxide 0.9.1", "zlib-rs", ] @@ -1396,12 +1492,6 @@ 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" @@ -1413,49 +1503,30 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.2" +version = "0.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cfebc7c6bb65d327ded064db65cd260b6c418c27ae790318650cfa2a81bf33f" +checksum = "9b2c81a5a0e7d67644309a15694eb7f414a4d6556c64c1ece1e5969aa609c8a9" dependencies = [ "derive_builder_fork_arti", "dirs", + "extend", "libc", "pwd-grp", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "void", "walkdir", ] -[[package]] -name = "fslock" -version = "0.2.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04412b8935272e3a9bae6f48c7bfff74c2911f60525404edfdd28e49884c3bfb" -dependencies = [ - "libc", - "winapi", -] - -[[package]] -name = "fslock-arti-fork" -version = "0.2.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b21bd626aaab7b904b20bef6d9e06298914a0c8d9fb8b010483766b2e532791" -dependencies = [ - "libc", - "winapi", -] - [[package]] name = "fslock-guard" -version = "0.6.0" +version = "0.8.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f62cb7d296f7d1fabdce7291281d9f0147b06a6e79afae05e1230eab667d85" +checksum = "d10ce5036d49f8add4a11d08bc1b1afb0e3cefdd88778ab87fb4202d47b765e1" dependencies = [ - "fslock-arti-fork", - "thiserror 2.0.18", - "winapi", + "libc", + "thiserror 2.0.21", + "windows-sys 0.61.2", ] [[package]] @@ -1466,9 +1537,9 @@ checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" [[package]] name = "futures" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +checksum = "9a31d2a3fbaaeb2af2368bbdd904aa8e812d3c04a1ee10d3171f52d556e5d0a3" dependencies = [ "futures-channel", "futures-core", @@ -1481,9 +1552,9 @@ dependencies = [ [[package]] name = "futures-channel" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +checksum = "b1f9e3d69d39e4862ffed03ed071a76f9a13ba1d9109d355b0f0aa6b15e393c4" dependencies = [ "futures-core", "futures-sink", @@ -1491,15 +1562,15 @@ dependencies = [ [[package]] name = "futures-core" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" [[package]] name = "futures-executor" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +checksum = "031b47cf1a3c6cc8bc2fc76cd437f521619387907d469316e7c0bc278f1f5432" dependencies = [ "futures-core", "futures-task", @@ -1508,19 +1579,19 @@ dependencies = [ [[package]] name = "futures-io" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" +checksum = "53c0fa8157de1303bfffdaa1cc2a673bfffb60102f76b0ef4441659124373fed" [[package]] name = "futures-macro" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +checksum = "9fb9654ba8355388abeb8dcb4fc62f511300867002afc858860463bdd9fe0c44" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -1536,21 +1607,21 @@ dependencies = [ [[package]] name = "futures-sink" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" +checksum = "1944426bf7d03f1d14f708785e4b33efd750b36d48a157b836b3efc15ede8e1d" [[package]] name = "futures-task" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" [[package]] name = "futures-util" -version = "0.3.32" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" dependencies = [ "futures-channel", "futures-core", @@ -1603,29 +1674,27 @@ dependencies = [ [[package]] name = "getrandom" -version = "0.4.2" +version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" dependencies = [ "cfg-if", "js-sys", "libc", "r-efi 6.0.0", - "wasip2", - "wasip3", + "rand_core 0.10.1", "wasm-bindgen", ] [[package]] name = "getset" -version = "0.1.6" +version = "0.1.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9cf0fc11e47561d47397154977bc219f4cf809b2974facc3ccb3b89e2436f912" +checksum = "6cf442baaabe4213ce7d1239afc26c039180b6456da2cededa316ae2c8a77a77" dependencies = [ - "proc-macro-error2", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -1669,7 +1738,7 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ - "foldhash 0.1.5", + "foldhash", ] [[package]] @@ -1677,9 +1746,6 @@ 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" @@ -1689,11 +1755,11 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "7382cf6263419f2d8df38c55d7da83da5c18aef87fc7a7fc1fb1e344edfe14c1" dependencies = [ - "hashbrown 0.16.1", + "hashbrown 0.15.5", ] [[package]] @@ -1734,18 +1800,15 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "html-escape" -version = "0.2.13" +version = "0.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6d1ad449764d627e22bfd7cd5e8868264fc9236e07c752972b4080cd351cb476" -dependencies = [ - "utf8-width", -] +checksum = "c9356095b4b41197bba32173600e1582792cda618f65d12f68e2e77d273413c5" [[package]] name = "http" -version = "1.4.1" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" dependencies = [ "bytes", "itoa", @@ -1753,9 +1816,9 @@ dependencies = [ [[package]] name = "http-body" -version = "1.0.1" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" dependencies = [ "bytes", "http", @@ -1763,9 +1826,9 @@ dependencies = [ [[package]] name = "http-body-util" -version = "0.1.3" +version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" dependencies = [ "bytes", "futures-core", @@ -1794,9 +1857,9 @@ checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" [[package]] name = "humantime" -version = "2.3.0" +version = "2.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135b12329e5e3ce057a9f972339ea52bc954fe1e9358ef27f95e89716fbc5424" +checksum = "15cdd26707701c53297e2fa6afb323d55fbc1d0810c3aec078ae3ef0424c3c15" [[package]] name = "humantime-serde" @@ -1810,18 +1873,19 @@ dependencies = [ [[package]] name = "hybrid-array" -version = "0.4.12" +version = "0.4.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9155a582abd142abc056962c29e3ce5ff2ad5469f4246b537ed42c5deba857da" +checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17" dependencies = [ "typenum", + "zeroize", ] [[package]] name = "hyper" -version = "1.10.1" +version = "1.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" +checksum = "27b501faa50e7a26c3d3560ca625132f4078a17771f4810baf70475ae48cbe43" dependencies = [ "atomic-waker", "bytes", @@ -1839,9 +1903,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.20" +version = "0.1.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" dependencies = [ "bytes", "http", @@ -1878,9 +1942,9 @@ dependencies = [ [[package]] name = "icu_collections" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +checksum = "fa68d21081c4a05d5a901a1c62add574c77048b6a1c67be3b50ce0b60d4ca513" dependencies = [ "displaydoc", "potential_utf", @@ -1892,9 +1956,9 @@ dependencies = [ [[package]] name = "icu_locale_core" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +checksum = "d56e28588da92eee5c3201a6eff33fabdd49b62269c8938d4ff050ce4d900deb" dependencies = [ "displaydoc", "litemap", @@ -1905,9 +1969,9 @@ dependencies = [ [[package]] name = "icu_normalizer" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +checksum = "12f9cf5f235641ed274641dd81c3f28d870e276763d0797aeeab72317b1c646f" dependencies = [ "icu_collections", "icu_normalizer_data", @@ -1919,16 +1983,17 @@ dependencies = [ [[package]] name = "icu_normalizer_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" +checksum = "1563da1ed3e0b3bf3d74c9b85917ac9c56464d2f57242270c09c9e752f8021a0" [[package]] name = "icu_properties" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +checksum = "7e7ca276ad3145661a65914e6daf131ca5120cd3dcee8f8f3214b8875184a148" dependencies = [ + "displaydoc", "icu_collections", "icu_locale_core", "icu_properties_data", @@ -1939,15 +2004,15 @@ dependencies = [ [[package]] name = "icu_properties_data" -version = "2.2.0" +version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" +checksum = "e590f038c1464a96894fd6d10127e90a8be4509f56ff7ecef851b15cee0b7caa" [[package]] name = "icu_provider" -version = "2.2.0" +version = "2.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +checksum = "d27bbb9d3abbefac45d55f647c9de1d44aafcd1186eb91879afef17c396c3e73" dependencies = [ "displaydoc", "icu_locale_core", @@ -1958,12 +2023,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "id-arena" -version = "2.3.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" - [[package]] name = "ident_case" version = "1.0.1" @@ -2027,9 +2086,9 @@ dependencies = [ [[package]] name = "indexmap" -version = "2.14.0" +version = "2.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" dependencies = [ "equivalent", "hashbrown 0.17.1", @@ -2039,29 +2098,29 @@ dependencies = [ [[package]] name = "infer" -version = "0.19.0" +version = "0.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a588916bfdfd92e71cacef98a63d9b1f0d74d6599980d11894290e7ddefffcf7" +checksum = "f4200d433cbd5178df7797c9c2e75b348b728e39631cf14520d1e2fc424201f4" dependencies = [ "cfb", ] [[package]] name = "inotify" -version = "0.11.2" +version = "0.11.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" +checksum = "4cc00ea907cab49550b7da656f80ebb97be1b997d931fbcd28d39734e17ce592" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", "inotify-sys", "libc", ] [[package]] name = "inotify-sys" -version = "0.1.5" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e05c02b5e89bff3b946cedeca278abc628fe811e604f027c45a8aa3cf793d0eb" +checksum = "c033f80b2c113cdf91ab7a33faa9cbc014726dcad99880c8609af2a370edf37d" dependencies = [ "libc", ] @@ -2075,6 +2134,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "inout" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4250ce6452e92010fdf7268ccc5d14faa80bb12fc741938534c58f16804e03c7" +dependencies = [ + "hybrid-array", +] + [[package]] name = "inventory" version = "0.3.24" @@ -2084,6 +2152,21 @@ dependencies = [ "rustversion", ] +[[package]] +name = "ipnet" +version = "2.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0" + +[[package]] +name = "iprange" +version = "0.6.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "37209be0ad225457e63814401415e748e2453a5297f9b637338f5fb8afa4ec00" +dependencies = [ + "ipnet", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -2092,9 +2175,9 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" [[package]] name = "itertools" -version = "0.14.0" +version = "0.15.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b192c782037fadd9cfa75548310488aabdbf3d2da73885b31bd0abd03351285" +checksum = "8b4baf93f58d4425749ca49a51c50ebab072c5df6994d08fed93541c331481dc" dependencies = [ "either", ] @@ -2105,15 +2188,68 @@ version = "1.0.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" +[[package]] +name = "jiff" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ab1baf72f08796de0260609515130699b890ac25f30e610ad894bc5856cafdb" +dependencies = [ + "defmt", + "jiff-core", + "jiff-static", + "jiff-tzdb-platform", + "log", + "portable-atomic", + "portable-atomic-util", + "serde_core", + "windows-link", +] + +[[package]] +name = "jiff-core" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e52fe76043ccecc9005d2305ebaadf7d7fc0cc89ca6baa10a94d6bc68c7128c" +dependencies = [ + "defmt", + "log", +] + +[[package]] +name = "jiff-static" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "378268a1116ad67ae6228701118ac9f491d78fda38a40a1f1a9e1348de6f7212" +dependencies = [ + "jiff-core", + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "jiff-tzdb" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "142bd39932ad231f10513df9ab62661fead8719872150b7ad02a2df79f4e141e" + +[[package]] +name = "jiff-tzdb-platform" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "875a5a69ac2bab1a891711cf5eccbec1ce0341ea805560dcd90b7a2e925132e8" +dependencies = [ + "jiff-tzdb", +] + [[package]] name = "js-sys" -version = "0.3.99" +version = "0.3.106" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "142bc4740e452c1e57ade0cbc129f139c9093e354346f0872ef985f4f5cf5f11" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" dependencies = [ "cfg-if", "futures-util", - "once_cell", "wasm-bindgen", ] @@ -2138,19 +2274,19 @@ dependencies = [ [[package]] name = "keccak" -version = "0.2.0" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9e24a010dd405bd7ed803e5253182815b41bf2e6a80cc3bfc066658e03a198aa" +checksum = "d8f198d1db720e4940b5a493201d199d9f24f568f8f746bd13706243a2f71598" dependencies = [ "cfg-if", - "cpufeatures 0.3.0", + "cpufeatures 0.3.1", ] [[package]] name = "kqueue" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" +checksum = "8d763e5b24120b4ddf50de6c92308156765aabfbbccebf401da7cff2d70a41ea" dependencies = [ "kqueue-sys", "libc", @@ -2162,7 +2298,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", "libc", ] @@ -2175,17 +2311,11 @@ dependencies = [ "spin", ] -[[package]] -name = "leb128fmt" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" - [[package]] name = "libc" -version = "0.2.186" +version = "0.2.189" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" [[package]] name = "libm" @@ -2195,18 +2325,18 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.17" +version = "0.1.25" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" +checksum = "61ff90caf6077a803a240f62fdbe88645a890bbca49ef8174c3cb0404362171d" dependencies = [ "libc", ] [[package]] name = "libsqlite3-sys" -version = "0.37.0" +version = "0.34.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b1f111c8c41e7c61a49cd34e44c7619462967221a6443b0ec299e0ac30cfb9b1" +checksum = "91632f3b4fb6bd1d72aa3d78f41ffecfcf2b1a6648d8c241dbe7dbfaf4875e15" dependencies = [ "cc", "pkg-config", @@ -2221,9 +2351,9 @@ checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" [[package]] name = "litemap" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" +checksum = "47d9d19d1d6efa0109d2f65ff4c85cddd50bd572e5a00127ab10987290bcefae" [[package]] name = "lock_api" @@ -2248,9 +2378,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.32" +version = "0.4.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" [[package]] name = "matchers" @@ -2269,15 +2399,15 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.1" +version = "2.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" [[package]] name = "memmap2" -version = "0.9.10" +version = "0.9.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "714098028fe011992e1c3962653c96b2d578c4b4bce9036e15ff220319b1e0e3" +checksum = "d1219ed1b7f229ee7104d281dd01d6802fe28bb6e95d292942c4daacdeb798c0" dependencies = [ "libc", ] @@ -2326,11 +2456,21 @@ dependencies = [ "simd-adler32", ] +[[package]] +name = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + [[package]] name = "mio" -version = "1.2.1" +version = "1.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" +checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" dependencies = [ "libc", "log", @@ -2355,6 +2495,12 @@ dependencies = [ "version_check", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nom" version = "7.1.3" @@ -2377,7 +2523,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", "inotify", "kqueue", "libc", @@ -2394,7 +2540,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", ] [[package]] @@ -2417,9 +2563,9 @@ dependencies = [ [[package]] name = "num-bigint" -version = "0.4.6" +version = "0.4.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" dependencies = [ "num-integer", "num-traits", @@ -2436,7 +2582,7 @@ dependencies = [ "num-integer", "num-iter", "num-traits", - "rand 0.8.6", + "rand 0.8.8", "smallvec", "zeroize", ] @@ -2449,20 +2595,19 @@ checksum = "521739c6d2bac4aa25192232afe6841231376b2b26d4d9fae5ecf8ca5772e441" [[package]] name = "num-integer" -version = "0.1.46" +version = "0.1.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f" +checksum = "7ce2d95d4b3734dc35aa2f45e1aa22cd416814592a4f9d9205e11affd5b8e10b" dependencies = [ "num-traits", ] [[package]] name = "num-iter" -version = "0.1.45" +version = "0.1.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1429034a0490724d0075ebb2bc9e875d6503c3cf69e235a8941aa757d83ef5bf" +checksum = "c92800bd69a1eac91786bcfe9da64a897eb72911b8dc3095decbd07429e8048b" dependencies = [ - "autocfg", "num-integer", "num-traits", ] @@ -2496,7 +2641,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2505,7 +2650,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", ] [[package]] @@ -2532,9 +2677,9 @@ checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" [[package]] name = "oneshot-fused-workaround" -version = "0.6.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e17b52d0e4a06a4c7eb8d2943c0015fa628cf4ccc409429cebc0f5bed6d33a82" +checksum = "56892f7dcba21c5eb4181b8de221620855805891ee55ef960aad7cac3b5d88b2" dependencies = [ "futures", ] @@ -2572,7 +2717,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2584,7 +2729,7 @@ dependencies = [ "ecdsa", "elliptic-curve", "primeorder", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2598,7 +2743,7 @@ dependencies = [ "elliptic-curve", "primeorder", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -2664,9 +2809,9 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "phf" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf" +checksum = "010378780309880b08997fae13be7834dba947d36393bd372f2b1556deb2a2f6" dependencies = [ "phf_macros", "phf_shared", @@ -2675,9 +2820,9 @@ dependencies = [ [[package]] name = "phf_generator" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737" +checksum = "aeb62e0959d5a1bebc965f4d15d9e2b7cea002b6b0f5ba8cde6cc26738467100" dependencies = [ "fastrand", "phf_shared", @@ -2685,22 +2830,22 @@ dependencies = [ [[package]] name = "phf_macros" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef" +checksum = "5fa8d0ca26d424d27630da600c6624696e7dec8bf7b3b492b383c5dc49e5e085" dependencies = [ "phf_generator", "phf_shared", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "phf_shared" -version = "0.13.1" +version = "0.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266" +checksum = "c6fd9027e2d9319be6349febd1db4e8d02aa544921200c9b777720ac34a3aa89" dependencies = [ "siphasher", ] @@ -2722,7 +2867,7 @@ checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -2754,9 +2899,9 @@ dependencies = [ [[package]] name = "pkg-config" -version = "0.3.33" +version = "0.3.34" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" +checksum = "f6b464fbc74e149a392436b17d523f769e057cb6877f6a5c4618bc6f11800548" [[package]] name = "png" @@ -2768,7 +2913,33 @@ dependencies = [ "crc32fast", "fdeflate", "flate2", - "miniz_oxide", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "polyval" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0fa31d631f2b2cb2a544d0aa321ce847a94764d701ca2becc411138b93d49cd" +dependencies = [ + "cpubits", + "cpufeatures 0.3.1", + "universal-hash", +] + +[[package]] +name = "portable-atomic" +version = "1.15.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05c8b63e8d9609db387f0324918f81d68fe27748f084ef092fb35954d0539a85" + +[[package]] +name = "portable-atomic-util" +version = "0.2.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10ab3eb7f3becc3a1cbc4f2c6f20267996cfc1a6467a873763411b136a122715" +dependencies = [ + "portable-atomic", ] [[package]] @@ -2788,9 +2959,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.5" +version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +checksum = "d83eb9bc6d8e5cf568e7a1101d60ee05e81ed50ea106026f3d18deeb046d7661" dependencies = [ "zerovec", ] @@ -2810,16 +2981,6 @@ dependencies = [ "zerocopy", ] -[[package]] -name = "prettyplease" -version = "0.2.37" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" -dependencies = [ - "proc-macro2", - "syn 2.0.117", -] - [[package]] name = "primeorder" version = "0.13.6" @@ -2836,7 +2997,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", ] @@ -2846,36 +3007,14 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.12+spec-1.1.0", -] - -[[package]] -name = "proc-macro-error-attr2" -version = "2.0.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96de42df36bb9bba5542fe9f1a054b8cc87e172759a1868aa05c1f3acc89dfc5" -dependencies = [ - "proc-macro2", - "quote", -] - -[[package]] -name = "proc-macro-error2" -version = "2.0.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11ec05c52be0a07b08061f7dd003e7d7092e0472bc731b4af7bb1ef876109802" -dependencies = [ - "proc-macro-error-attr2", - "proc-macro2", - "quote", - "syn 2.0.117", + "toml_edit 0.25.15+spec-1.1.0", ] [[package]] name = "proc-macro2" -version = "1.0.106" +version = "1.0.107" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" dependencies = [ "unicode-ident", ] @@ -2889,14 +3028,14 @@ dependencies = [ "derive-deftly", "libc", "paste", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "quote" -version = "1.0.45" +version = "1.0.47" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" dependencies = [ "proc-macro2", ] @@ -2921,9 +3060,9 @@ checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" [[package]] name = "rand" -version = "0.8.6" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "rand_chacha 0.3.1", "rand_core 0.6.4", @@ -2931,14 +3070,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.4" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.3", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.3.1" @@ -2959,6 +3109,16 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand_chacha" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e6af7f3e25ded52c41df4e0b1af2d047e45896c2f3281792ed68a1c243daedb" +dependencies = [ + "ppv-lite86", + "rand_core 0.10.1", +] + [[package]] name = "rand_core" version = "0.6.4" @@ -2977,24 +3137,37 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_jitter" -version = "0.5.0" +version = "0.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b16df48f071248e67b8fc5e866d9448d45c08ad8b672baaaf796e2f15e606ff0" +checksum = "3fdcd80e68f0a8f9ca5ec7cfd02fd5fbb8fbe6ef4e9b90ea2f48bb929b74f88e" dependencies = [ "libc", - "rand_core 0.9.5", - "winapi", + "rand_core 0.10.1", + "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 = "rdrand" -version = "0.8.3" +version = "0.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d92195228612ac8eed47adbc2ed0f04e513a4ccb98175b6f2bd04d963b533655" +checksum = "84448986e59427c795b929d8dbe12d176275c7d0887ee48098c35b7206f51bae" dependencies = [ - "rand_core 0.6.4", + "libc", + "rand_core 0.10.1", ] [[package]] @@ -3003,45 +3176,44 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", ] [[package]] name = "redox_users" -version = "0.5.2" +version = "0.5.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4e608c6638b9c18977b00b475ac1f28d14e84b27d8d42f70e0bf1e3dec127ac" +checksum = "60dc65c0ff1a7ae1294b0c67b9f14baf70b644404010370171787bfac1038fc0" dependencies = [ - "getrandom 0.2.17", "libredox", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] name = "ref-cast" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f354300ae66f76f1c85c5f84693f0ce81d747e2c3f21a45fef496d89c960bf7d" +checksum = "7e440fb4e4b4147295338efb76001ab9e4efc0e5839df2c47fc5ac2381d365c3" dependencies = [ "ref-cast-impl", ] [[package]] name = "ref-cast-impl" -version = "1.0.25" +version = "1.0.27" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" +checksum = "92ecd8964f8453721699a1ed72037b0db49ce2f5a5138486ee89bed6f67cdf3a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "regex" -version = "1.12.3" +version = "1.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +checksum = "f020237b6c8eed93db2e2cb53c00c60a8e1bc73da7d073199a1180401450218d" dependencies = [ "aho-corasick", "memchr", @@ -3051,9 +3223,9 @@ dependencies = [ [[package]] name = "regex-automata" -version = "0.4.14" +version = "0.4.18" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" dependencies = [ "aho-corasick", "memchr", @@ -3062,15 +3234,24 @@ dependencies = [ [[package]] name = "regex-syntax" -version = "0.8.10" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "reseeding_rng" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +checksum = "ecfc8e855ab517235426ec1290bfb0745e73fa478b29619eb66a2a81be9d8716" +dependencies = [ + "rand_core 0.10.1", +] [[package]] name = "retry-error" -version = "0.12.0" +version = "0.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf6aa271ee564cc5d1df57c5cf7c6ac7a21a4f9f40d2bf1d32bf0a1bb3ddaeb0" +checksum = "d350ad359aca3414f7261cdcc763e37ccc9fa56fe40ca30a9758c28a295bcad0" dependencies = [ "humantime", "web-time", @@ -3123,36 +3304,25 @@ dependencies = [ "pkcs1", "pkcs8", "rand_core 0.6.4", - "sha2", + "sha2 0.10.9", "signature", "spki", "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.18", -] - [[package]] name = "rusqlite" -version = "0.39.0" +version = "0.36.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" +checksum = "3de23c3319433716cf134eed225fe9986bc24f63bed9be9f20c329029e672dc7" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", "fallible-iterator", "fallible-streaming-iterator", "hashlink", "libsqlite3-sys", "smallvec", - "sqlite-wasm-rs", "time", ] @@ -3176,11 +3346,11 @@ dependencies = [ [[package]] name = "rustix" -version = "1.1.4" +version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", "errno", "libc", "linux-raw-sys", @@ -3189,9 +3359,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.40" +version = "0.23.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" +checksum = "0d41d731c7d2f962d1ccc364cec258de3c0e93b38c2fb3ba97ac74513048d634" dependencies = [ "log", "once_cell", @@ -3204,18 +3374,18 @@ dependencies = [ [[package]] name = "rustls-pki-types" -version = "1.14.1" +version = "1.15.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "30a7197ae7eb376e574fe940d068c30fe0462554a3ddbe4eca7838e049c937a9" +checksum = "2f4925028c7eb5d1fcdaf196971378ed9d2c1c4efc7dc5d011256f76c99c0a96" dependencies = [ "zeroize", ] [[package]] name = "rustls-webpki" -version = "0.103.13" +version = "0.103.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" +checksum = "f3c3cf1d8b1e7d4927e2d154c3fcb02979afb9939629c62cd9048d4f07b60ac2" dependencies = [ "ring", "rustls-pki-types", @@ -3224,33 +3394,33 @@ dependencies = [ [[package]] name = "rustpost" -version = "0.2.0" +version = "1.0.0" dependencies = [ "anyhow", "argon2", "arti-client", "axum", - "base64 0.22.1", + "base64 0.23.1", "captcha", "chrono", "clap", "flate2", "futures-util", + "getrandom 0.4.3", "html-escape", "http-body-util", "infer", - "rand_core 0.6.4", "rusqlite", "rustls", "safelog", "serde", "serde_json", - "sha2", + "sha2 0.11.0", "tar", "tempfile", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", - "toml 0.9.12+spec-1.1.0", + "toml 1.1.6+spec-1.1.0", "tor-cell", "tor-hsservice", "tor-proto", @@ -3265,9 +3435,9 @@ dependencies = [ [[package]] name = "rustversion" -version = "1.0.22" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" [[package]] name = "ryu" @@ -3277,15 +3447,15 @@ checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" [[package]] name = "safelog" -version = "0.8.2" +version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97a907e0d82c61b1b06a2030c968eb313dcf432686b77801a26bc4b206f96573" +checksum = "73ef1d6e273fc8ce5cc1376cf498c06d23b786e80e4a7679a643bb0a838b0029" dependencies = [ "derive_more", "educe", "either", "fluid-let", - "thiserror 2.0.18", + "thiserror 2.0.21", ] [[package]] @@ -3308,9 +3478,9 @@ dependencies = [ [[package]] name = "saturating-time" -version = "0.3.0" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b63583a1dd0647d1484228529ab4ecaa874048d2956f117362aa5f5826456230" +checksum = "802bdbfcca9a239cb46eeaaedea507e37bb13ba1a673762d2e2ef7a9dac63144" [[package]] name = "schemars" @@ -3326,9 +3496,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.2.1" +version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2b42f36aa1cd011945615b92222f6bf73c599a102a300334cd7f8dbeec726cc" +checksum = "687274d293b6cdc6e73e0fee520bf2049650090d7164f87672d212a3c530cf4a" dependencies = [ "dyn-clone", "ref-cast", @@ -3364,9 +3534,9 @@ checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" [[package]] name = "serde" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" dependencies = [ "serde_core", "serde_derive", @@ -3394,22 +3564,22 @@ dependencies = [ [[package]] name = "serde_core" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.228" +version = "1.0.229" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] @@ -3424,9 +3594,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.150" +version = "1.0.151" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8014e44b4736ed0538adeecded0fce2a272f22dc9578a7eb6b2d9993c74cfb9" +checksum = "c841b55ecdae098c80dcae9cf767f6f8a0c2cdb3416bbef72181df4d0fe73f14" dependencies = [ "itoa", "memchr", @@ -3478,18 +3648,19 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.21.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" +checksum = "df9adc193c780ef8f159aee8b61e2d5801aaa555e6eb0947fe45530ec506296f" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "bs58", "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.14.0", + "indexmap 2.14.2", + "jiff", "schemars 0.9.0", - "schemars 1.2.1", + "schemars 1.2.2", "serde_core", "serde_json", "serde_with_macros", @@ -3498,21 +3669,21 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.21.0" +version = "3.24.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" +checksum = "3e17bbc68e28663bbbb90df47e058aa7eda4fb445b89fe70457bb94fbccf6e49" dependencies = [ - "darling 0.23.0", + "darling 0.24.1", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "sha1" -version = "0.10.6" +version = "0.10.7" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +checksum = "a978451301f4db1d02937a4ab3ccce137717b81826e79b7d49ffe3244a13c3b8" dependencies = [ "cfg-if", "cpufeatures 0.2.17", @@ -3530,6 +3701,17 @@ dependencies = [ "digest 0.10.7", ] +[[package]] +name = "sha2" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "digest 0.11.3", +] + [[package]] name = "sha3" version = "0.10.9" @@ -3547,7 +3729,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bc9bad02c26382724b2d2692c6f179285e4b54eeecd7968f52a50059c3c11759" dependencies = [ "digest 0.11.3", - "keccak 0.2.0", + "keccak 0.2.2", "sponge-cursor", ] @@ -3599,15 +3781,21 @@ dependencies = [ [[package]] name = "simd-adler32" -version = "0.3.9" +version = "0.3.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "simdutf8" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e" [[package]] name = "siphasher" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +checksum = "33f4fe9184a62d842c9ef383018f3306d8ba224fd9d836f56d7288308847c256" [[package]] name = "slab" @@ -3627,28 +3815,28 @@ dependencies = [ [[package]] name = "slotmap-careful" -version = "0.7.0" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ed92816c1fbb29891a525b92d5fa95757c9dee47044f76c8e06ceb1e052a8d64" +checksum = "ba7bcbd01d9b495f4509e74f660ebaf3315219b2f8a81753667ee7d6d8a199ac" dependencies = [ "paste", "serde", "slotmap", - "thiserror 2.0.18", + "thiserror 2.0.21", "void", ] [[package]] name = "smallvec" -version = "1.15.1" +version = "1.16.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" +checksum = "f9395f0f0eee849a9b707b2f06bb92a6a422090e2123bb2ef8e87a0e61892a8e" [[package]] name = "socket2" -version = "0.6.4" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", "windows-sys 0.61.2", @@ -3656,9 +3844,9 @@ dependencies = [ [[package]] name = "spin" -version = "0.9.8" +version = "0.9.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6980e8d7511241f8acf4aebddbb1ff938df5eebe98691418c4468d0b72a96a67" +checksum = "3763264f6b73151db08c50ff20d7d8a0b8796e021cdea7ceedad07b80155fa0e" [[package]] name = "spki" @@ -3676,25 +3864,13 @@ 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" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "125c5795103fc93fced42d123c8044180afc55469caa1ab56487c3c5543c898d" dependencies = [ - "cipher", + "cipher 0.4.4", "ssh-encoding-fork-arti", ] @@ -3706,7 +3882,7 @@ checksum = "e0cf03c3a7ea88451ff83a129a79451fd9891d44fc76c25e916a11848b81814c" dependencies = [ "base64ct", "pem-rfc7468", - "sha2", + "sha2 0.10.9", ] [[package]] @@ -3722,7 +3898,7 @@ dependencies = [ "rand_core 0.6.4", "rsa", "sec1", - "sha2", + "sha2 0.10.9", "signature", "ssh-cipher-fork-arti", "ssh-encoding-fork-arti", @@ -3772,7 +3948,7 @@ dependencies = [ "heck", "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -3794,9 +3970,20 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.117" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +checksum = "8593e8e72159ed2257d083c7a454a85cbf854f37a0966d8d483aff8c8a3ebcee" dependencies = [ "proc-macro2", "quote", @@ -3817,7 +4004,18 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", +] + +[[package]] +name = "synstructure" +version = "0.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "901704edd0dfe137f1987838ee4f259e4e063c31371bdb423f7ae38ec6f77f02" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.6", ] [[package]] @@ -3858,7 +4056,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", - "getrandom 0.4.2", + "getrandom 0.4.3", "once_cell", "rustix", "windows-sys 0.61.2", @@ -3875,11 +4073,11 @@ dependencies = [ [[package]] name = "thiserror" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +checksum = "09e52cb86a36cede5cb101bf8908837b3e4c6e5e59fe7fd85c23fb56200d189e" dependencies = [ - "thiserror-impl 2.0.18", + "thiserror-impl 2.0.21", ] [[package]] @@ -3890,38 +4088,36 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "thiserror-impl" -version = "2.0.18" +version = "2.0.21" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +checksum = "fe5197923287db20a58125f0bc85c062f7f2c892de97b18c356f9efb14b28524" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "thread_local" -version = "1.1.9" +version = "1.1.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" dependencies = [ "cfg-if", ] [[package]] name = "time" -version = "0.3.47" +version = "0.3.55" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "743bd48c283afc0388f9b8827b976905fb217ad9e647fae3a379a9283c4def2c" +checksum = "cdb87b95ec50ddfa440816d227a17b2ccbdda963a316a727fda0fc4334f7d134" dependencies = [ "deranged", - "itoa", - "js-sys", "num-conv", "powerfmt", "serde_core", @@ -3931,15 +4127,15 @@ dependencies = [ [[package]] name = "time-core" -version = "0.1.8" +version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7694e1cfe791f8d31026952abf09c69ca6f6fa4e1a1229e18988f06a04a12dca" +checksum = "9e1c906769ad99c88eaa54e728060edef082f8e358ff32030cb7c7d315e81109" [[package]] name = "time-macros" -version = "0.2.27" +version = "0.2.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2e70e4c5a0e0a8a4823ad65dfe1a6930e4f4d756dcd9dd7939022b5e8c501215" +checksum = "7e689342a48d2ea927c87ea50cabf8594854bf940e9310208848d680d668ed85" dependencies = [ "num-conv", "time-core", @@ -3947,9 +4143,9 @@ dependencies = [ [[package]] name = "tinystr" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +checksum = "b1e27c91459209c2986af3dcf603a5a74a4368754ce37414f59acc971167f643" dependencies = [ "displaydoc", "serde_core", @@ -3958,24 +4154,15 @@ dependencies = [ [[package]] name = "tinyvec" -version = "1.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3e61e67053d25a4e82c844e8424039d9745781b3fc4f32b8d55ed50f5f667ef3" -dependencies = [ - "tinyvec_macros", -] - -[[package]] -name = "tinyvec_macros" -version = "0.1.1" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" +checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.52.3" +version = "1.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" dependencies = [ "bytes", "libc", @@ -3989,20 +4176,20 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.7.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "tokio-util" -version = "0.7.18" +version = "0.7.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +checksum = "494815d09bf52b5548659851081238f0ca39ff638363907596da739561c62c52" dependencies = [ "bytes", "futures-core", @@ -4026,32 +4213,17 @@ dependencies = [ [[package]] name = "toml" -version = "0.9.12+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" -dependencies = [ - "indexmap 2.14.0", - "serde_core", - "serde_spanned 1.1.1", - "toml_datetime 0.7.5+spec-1.1.0", - "toml_parser", - "toml_writer", - "winnow 0.7.15", -] - -[[package]] -name = "toml" -version = "1.1.2+spec-1.1.0" +version = "1.1.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "81f3d15e84cbcd896376e6730314d59fb5a87f31e4b038454184435cd57defee" +checksum = "920602543f0911ab71da12c50d59701da54c196d1a2bf5cb4b75667f137a406a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde_core", "serde_spanned 1.1.1", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", "toml_writer", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4063,15 +4235,6 @@ dependencies = [ "serde", ] -[[package]] -name = "toml_datetime" -version = "0.7.5+spec-1.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" -dependencies = [ - "serde_core", -] - [[package]] name = "toml_datetime" version = "1.1.1+spec-1.1.0" @@ -4087,7 +4250,7 @@ version = "0.22.27" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "serde", "serde_spanned 0.6.9", "toml_datetime 0.6.11", @@ -4097,23 +4260,23 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.12+spec-1.1.0" +version = "0.25.15+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" +checksum = "1340ea94a5856333492c9064b02c778b191dd2c853778d9609debdcdfea3a614" dependencies = [ - "indexmap 2.14.0", + "indexmap 2.14.2", "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] name = "toml_parser" -version = "1.1.2+spec-1.1.0" +version = "1.1.3+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2abe9b86193656635d2411dc43050282ca48aa31c2451210f4202550afb7526" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" dependencies = [ - "winnow 1.0.3", + "winnow 1.0.4", ] [[package]] @@ -4124,61 +4287,75 @@ checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" [[package]] name = "toml_writer" -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 = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" [[package]] name = "tor-async-utils" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee2d3e0d74d393dea1c73e5e3ca6a0f0c7920c21b4c72ae9820bc85ba86db2b" +checksum = "31d9f28de36ed7646f0f03835ce2e306e2c5df8a1784a2cac27bd549cdc07599" dependencies = [ + "cfg-if", "derive-deftly", "educe", + "extend", "futures", "oneshot-fused-workaround", "pin-project", "postage", - "thiserror 2.0.18", + "slotmap-careful", + "sync_wrapper", + "thiserror 2.0.21", + "tokio", + "tokio-util", + "tor-basic-utils", + "tor-rtcompat", + "tracing", "void", + "web-time-compat", ] [[package]] name = "tor-basic-utils" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a20878eacdfbcd1fed2ee27092e66ccee2df871e0a6b92b6658efd59f739230" +checksum = "1a2bd42110ae0faea1d446b35899aa5c3591cd31a94b593aa9264f00baba41da" dependencies = [ + "derive-deftly", "derive_more", - "getrandom 0.4.2", + "educe", + "extend", + "getrandom 0.4.3", "hex", "itertools", "libc", "paste", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.3", + "rand_chacha 0.10.0", "serde", "slab", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", + "tracing", "weak-table", "web-time-compat", ] [[package]] name = "tor-bytes" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428748cf8cf3f2bd1a4cc30bab33512b1a7d3f8b1f2abd02e39cbcbbebc05aed" +checksum = "73de2ba54a125c27a440616f2d806a171bcb4e30013de166c7dc0a79bde44ab4" dependencies = [ "bytes", "derive-deftly", "digest 0.10.7", "educe", - "getrandom 0.4.2", + "getrandom 0.4.3", "safelog", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "zeroize", @@ -4186,12 +4363,12 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efd607ea595674678f73ebf77c245caf04e1d229eae8caf58d2989ff6c63959" +checksum = "118f2b6a56605bedd077003ee5678fda215965d550dd0aef21ce8e10dd082750" dependencies = [ "amplify", - "bitflags 2.12.1", + "bitflags 2.13.2", "bytes", "caret", "derive-deftly", @@ -4199,9 +4376,9 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.9.4", + "rand 0.10.3", "smallvec", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-cert", @@ -4217,15 +4394,16 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6baf9c4f05d2c90e46fcb404039c2fe9628c2b56ef0101bb7bcb5f8e8125c8b1" +checksum = "b2cec6ae3abafc382e126a26e74f000bffbbf2c9b1a8c9ec26bb84463f0120b4" dependencies = [ "caret", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", - "thiserror 2.0.18", + "saturating-time", + "thiserror 2.0.21", "tor-bytes", "tor-checkable", "tor-error", @@ -4235,9 +4413,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec7e9542c0100f974b0953f3c497755044d46a07fec5e23c1738fb5c5036c9" +checksum = "6cef673b170f10790ff81d2f190e0a8150e5c9683c2c8f4c09652a6954c0e5ab" dependencies = [ "async-trait", "base64ct", @@ -4251,11 +4429,11 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.9.4", + "rand 0.10.3", "safelog", "serde", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4278,22 +4456,23 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b54e6c8c206b5fc1d49bfb000a19d3feb1b69650eb99bd51d68964882827527" +checksum = "d3872a5bda49647ad4c4a822c58774995ad3acd02327f1cabe6cfc019d714535" dependencies = [ "humantime", + "itertools", "signature", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-llcrypto", "web-time-compat", ] [[package]] name = "tor-circmgr" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2402d71b92ff5221497713f7fdb124bd3861213382f7dee5cd93dd4c98ef5abf" +checksum = "2cf1eb6c2e3184c9e51c746df81f1a890ba7b663ac418debe2dac3756d840c8a" dependencies = [ "amplify", "async-trait", @@ -4310,11 +4489,11 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.9.4", + "rand 0.10.3", "retry-error", "safelog", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-cell", @@ -4341,9 +4520,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b63f57706747de9221c97785c6dca52231af9c798cbcc49ac5c8bf1a70a69ea" +checksum = "dd8ca5684100c9223590c11685f3f39c4e4c0037c65daddc26927b3ae4cc893d" dependencies = [ "amplify", "cfg-if", @@ -4351,6 +4530,7 @@ dependencies = [ "derive_builder_fork_arti", "educe", "either", + "extend", "figment", "fs-mistrust", "futures", @@ -4364,8 +4544,8 @@ dependencies = [ "serde-value", "serde_ignored", "strum", - "thiserror 2.0.18", - "toml 1.1.2+spec-1.1.0", + "thiserror 2.0.21", + "toml 1.1.6+spec-1.1.0", "tor-basic-utils", "tor-error", "tor-rtcompat", @@ -4375,30 +4555,30 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6010b61d49d2fa3f02957c237a8c70bf37f100e9408473965e67ce7d834e8d9" +checksum = "cbed06099bacbdbb1d91cfff1032863896d6097c33eece067ab20756e4a6fa2b" dependencies = [ "directories", "serde", "shellexpand", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-error", "tor-general-addr", ] [[package]] name = "tor-consdiff" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "499d69ee59f762ed7c4a68d87af2f1bc8a03735d43649217598b339e3031631f" +checksum = "9760d0051159a257b1d0065615ad6bc023da4e178d41ff5640222a29852de848" dependencies = [ "derive_more", "digest 0.10.7", "hex", "imara-diff", "static_assertions", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-error", "tor-llcrypto", "tor-netdoc", @@ -4406,9 +4586,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d30d7a9a7b17ea5828dc171046f3a29b8e10b5c20a1ade257f744386f25aa4" +checksum = "403a79a0d00de6c78f15773a284e790459cff6983449e8578490e6b5dfb2afcd" dependencies = [ "async-compression", "base64ct", @@ -4420,7 +4600,7 @@ dependencies = [ "httpdate", "itertools", "memchr", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-circmgr", "tor-error", "tor-hscrypto", @@ -4435,9 +4615,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4988c5af5d57fd6590ae7a50015366ebd54fbf2411247ff5a2346119493a078" +checksum = "dd5cd2a4f641ecb007cc50914bb1b46128de0975aba08baa56497c8e03c71c71" dependencies = [ "base64ct", "derive-deftly", @@ -4456,9 +4636,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adeefa259f7d03ceb29bfbb631b786361fcba6234676d2f8ff71ae1820748f04" +checksum = "805da4e947311285ce0782ab129df318701c2c08046c316086cb6bb3eb7a463e" dependencies = [ "async-trait", "base64ct", @@ -4468,7 +4648,7 @@ dependencies = [ "educe", "event-listener", "fs-mistrust", - "fslock", + "fslock-guard", "futures", "hex", "humantime", @@ -4478,7 +4658,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.9.4", + "rand 0.10.3", "rusqlite", "safelog", "scopeguard", @@ -4487,7 +4667,7 @@ dependencies = [ "signature", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -4513,9 +4693,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6728477fffe0d1ce98410635b1c690221e3d1d596e436aafa46d6a255d6466" +checksum = "08f29e906f9f333a6e32e32afac9fb6712ac340191eb3292295b3337682b2dad" dependencies = [ "derive_more", "futures", @@ -4523,7 +4703,7 @@ dependencies = [ "retry-error", "static_assertions", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tracing", "void", "web-time-compat", @@ -4531,20 +4711,20 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163dd5f727f4e98975dbb9f4ddc3e6129f0008985686334d5c5ff86b4733d112" +checksum = "7b45ecd95dbf79070f9e477e1d84b22e47f03b2ab2f853abe31887daa9090cf0" dependencies = [ "derive_more", - "thiserror 2.0.18", + "thiserror 2.0.21", "void", ] [[package]] name = "tor-guardmgr" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4cc40821c4b68f376a443ddd8530314efa1ebd38a1434b3ddb0c8555d2be972" +checksum = "3e84beedbf276ce4ea170206b38ca01c995dbf1b9f678e9708f50e2e71dbfef4" dependencies = [ "amplify", "base64ct", @@ -4561,11 +4741,11 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.4", + "rand 0.10.3", "safelog", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4586,11 +4766,12 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8df5e4ff97bc4086993c0943687f35f4565b0f767029c75ed46780a584f1eb" +checksum = "3eba66be03b5eb899185e5b4416d8893fdb09a97b33db9e450042f06e2550f0e" dependencies = [ "async-trait", + "cfg-if", "derive-deftly", "derive_more", "educe", @@ -4599,12 +4780,12 @@ dependencies = [ "itertools", "oneshot-fused-workaround", "postage", - "rand 0.9.4", + "rand 0.10.3", "retry-error", "safelog", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4631,11 +4812,11 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f1db46a8bd6307f104dcc0a11f56a5b05f5823cc9d11e1748402daa940d3c7" +checksum = "118ff6a69bfa111b6e8d087ffb107ea9cbcd5d4469295cd7ada1b3cf19ba5a0f" dependencies = [ - "cipher", + "cipher 0.5.2", "data-encoding", "derive-deftly", "derive_more", @@ -4644,12 +4825,12 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.9.4", + "rand 0.10.3", "safelog", "serde", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-error", @@ -4664,9 +4845,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aba207bceef6973f0607e92ee5606a001422c52a2c85ee95bf3964d8099c4ee" +checksum = "d61a6b5217c14780f54fa1c8610514b1349c93f657304dec98bd8e3083e1a09f" dependencies = [ "amplify", "async-trait", @@ -4687,14 +4868,14 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.3", + "rand_core 0.10.1", "retry-error", "safelog", "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-bytes", @@ -4723,19 +4904,19 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d9ae1ee46f439cfeb934bb1815ec460b94c1b6fce51b3b378d5253a1f99ce5b" +checksum = "1861b5eebab93515a0ab2401540f3d5a9fb376f1b1535d5793dcd697fb19f928" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.9.4", + "rand 0.10.3", "rsa", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-bytes", "tor-cert", "tor-checkable", @@ -4745,9 +4926,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e968a3720ae845d1e4142d86b194cfa34c1e6f1c81eef29a5a285fc897f809" +checksum = "b3088a573dadebb34acacb30d686751e2a94d12ccf9d6647946ab6c6b4ad5a0e" dependencies = [ "amplify", "arrayvec", @@ -4762,12 +4943,12 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.9.4", + "rand 0.10.3", "safelog", "serde", "signature", "ssh-key-fork-arti", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4786,9 +4967,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "048c5fe9e697c76f6326d73255ae9cc107a6b4f161b8b552f25f10157e31f85e" +checksum = "6d8d3a2aaf3bb67110218b9ff1718cd7d6c7e62226e2fee1a18efa6aa19ff07c" dependencies = [ "base64ct", "by_address", @@ -4802,7 +4983,7 @@ dependencies = [ "serde", "serde_with", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", "tor-config", @@ -4813,9 +4994,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a40374cd6525a416d5131afeb7b2d9ea9ce838f32bd6175032b5a43a507c16" +checksum = "4fee81755528eb34f8352ca6987d5f5c66ebba6af2e4618da4b527f17b0e912e" dependencies = [ "aes", "base64ct", @@ -4829,23 +5010,24 @@ dependencies = [ "educe", "getrandom 0.2.17", "getrandom 0.3.4", - "getrandom 0.4.2", + "getrandom 0.4.3", "hex", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.3", + "rand_chacha 0.10.0", + "rand_core 0.10.1", "rand_core 0.6.4", - "rand_core 0.9.5", "rand_jitter", "rdrand", + "reseeding_rng", "rsa", "safelog", "serde", "sha1", - "sha2", + "sha2 0.10.9", "sha3 0.10.9", "signature", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-error", "tor-memquota-cost", "visibility", @@ -4855,13 +5037,13 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ef1a9811152abad23de9783d21f72a1a89cd0655a8fae6f61e9b975679fce6" +checksum = "2f8696c8ea9090059d91eea287628b5df0b3b4fba433179d893e348857857544" dependencies = [ "futures", "humantime", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-error", "tor-rtcompat", "tracing", @@ -4871,15 +5053,16 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f170000c5fe0bef7f476b0b683a478e34b70f0ed6c2ad69d4b672aa93c101aa5" +checksum = "03786604757846a5f936285eef397e3fc0ed455893dc5012ceafdf455ada0d8d" dependencies = [ "cfg-if", "derive-deftly", "derive_more", "dyn-clone", "educe", + "extend", "futures", "itertools", "paste", @@ -4888,7 +5071,7 @@ dependencies = [ "slotmap-careful", "static_assertions", "sysinfo", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-async-utils", "tor-basic-utils", "tor-config", @@ -4902,9 +5085,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17475dcc5d5ddf008043c138a38ec70a8448c9509e099fa86be09ab92ee3601e" +checksum = "86d6f7e1d842e6272e414e1f866dc688306b788d368df42e47c4995e040c679c" dependencies = [ "derive-deftly", "itertools", @@ -4914,12 +5097,12 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1351ffe2fff8bad7d5f2b79ec88ea188e159fe94cf705b1e485d8566526a7b5" +checksum = "9d3b84edfe732b77fb1420347902c06d92a1e6992e378ad19da6bdadcb8cd798" dependencies = [ "async-trait", - "bitflags 2.12.1", + "bitflags 2.13.2", "derive_more", "digest 0.10.7", "futures", @@ -4927,10 +5110,10 @@ dependencies = [ "humantime", "itertools", "num_enum", - "rand 0.9.4", + "rand 0.10.3", "serde", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tor-basic-utils", "tor-error", @@ -4947,27 +5130,31 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505b69e589e042276da708f7baa44d9f16ec50074a3f54ebb3bcbbcd6c4d9aa" +checksum = "5b6abd7aaeb2bdbb3fbf760aab9aaa4e8e1a0f8b84d93a834df5a16882faa31e" dependencies = [ "amplify", "base64ct", - "cipher", + "cipher 0.5.2", "derive-deftly", "derive_builder_fork_arti", "derive_more", "digest 0.10.7", "educe", "enumset", + "extend", "hex", "hostname-validator", "humantime", + "ipnet", + "iprange", "itertools", "memchr", "paste", "phf", - "rand 0.9.4", + "rand 0.10.3", + "rangemap", "saturating-time", "serde", "serde_with", @@ -4975,7 +5162,7 @@ dependencies = [ "smallvec", "strum", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tinystr", "tor-basic-utils", @@ -4996,16 +5183,16 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d574ac9a23c15d55d77a7c472294e762a4373bf0030e19d192eb523f1393ea60" +checksum = "e6f2a41d13a685e181361cfc3bdf25d319b96b56f5806594b6b8ed760d0099ed" dependencies = [ "amplify", "derive-deftly", "derive_more", + "extend", "filetime", "fs-mistrust", - "fslock", "fslock-guard", "futures", "itertools", @@ -5014,7 +5201,7 @@ dependencies = [ "sanitize-filename", "serde", "serde_json", - "thiserror 2.0.18", + "thiserror 2.0.21", "time", "tor-async-utils", "tor-basic-utils", @@ -5026,10 +5213,11 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c97a19e322dab933540e50ab0b987e7f9946394b4e2884976ba4d371562e531" +checksum = "0ddf767335ae2e255025536d48384b92c6b4b8557e5b345be600e7ca2de1c880" dependencies = [ + "aes", "amplify", "async-trait", "asynchronous-codec", @@ -5037,8 +5225,9 @@ dependencies = [ "bytes", "caret", "cfg-if", - "cipher", + "cipher 0.5.2", "coarsetime", + "ctr", "derive-deftly", "derive_builder_fork_arti", "derive_more", @@ -5053,17 +5242,17 @@ dependencies = [ "nonany", "oneshot-fused-workaround", "pin-project", + "polyval", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.3", + "rand_core 0.10.1", "safelog", "slotmap-careful", "smallvec", "static_assertions", "strum", "subtle", - "sync_wrapper", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-async-utils", @@ -5093,25 +5282,26 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2afba54106ccf364d8d422d7e33510e21e8004129574609ba26d2d8a2da1b81" +checksum = "cab080a7551561bd56c0c2e3578b7114f0853700b96e43613fabc1fe06e5d885" dependencies = [ "caret", + "derive-deftly", "paste", "serde_with", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-basic-utils", "tor-bytes", ] [[package]] name = "tor-relay-selection" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede9f32476abff4c61843bf3eba06ce03751fc62f472e0d9622185a5bfd1b009" +checksum = "07a5c22b7cfd59c40e38d4e111906793c0d65cf881e31efe3a83cac42887b1c9" dependencies = [ - "rand 0.9.4", + "rand 0.10.3", "serde", "tor-basic-utils", "tor-linkspec", @@ -5121,15 +5311,17 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4cbf776b2c52614b0ebaec7c2b93782bb5b61233f474ae46331976e38d8bbdc" +checksum = "542f4e13054bbba8d764ee24178fd889f77afb59dca7c6ae0a370604118c20b6" dependencies = [ + "amplify", "async-trait", "async_executors", "asynchronous-codec", "cfg-if", "coarsetime", + "derive_builder_fork_arti", "derive_more", "dyn-clone", "educe", @@ -5143,7 +5335,7 @@ dependencies = [ "rustls-pki-types", "rustls-webpki", "socket2", - "thiserror 2.0.18", + "thiserror 2.0.21", "tokio", "tokio-util", "tor-error", @@ -5156,9 +5348,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a733ab3432e24f36b6f6680cc92413c61bb9a2041e09a17ac465cc3f2a788e5" +checksum = "04cb78b3a39634e46c1bc795f62fca17b4d8a9404fead18750592a03216e1da3" dependencies = [ "amplify", "assert_matches", @@ -5174,7 +5366,7 @@ dependencies = [ "priority-queue", "slotmap-careful", "strum", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-error", "tor-general-addr", "tor-rtcompat", @@ -5186,9 +5378,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b29d42752715cdf679ff7537f26457e2aabeabb682deec98beb79f01c8ee7bd" +checksum = "2f96e25e5d36b4aff59af40ec86f7ebb227a32b2f726ef1bc8cccf927f8fb31a" dependencies = [ "amplify", "caret", @@ -5196,21 +5388,21 @@ dependencies = [ "educe", "safelog", "subtle", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-bytes", "tor-error", ] [[package]] name = "tor-units" -version = "0.42.0" +version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527324d70bf67aadaa23fb6ebc585aae5ab7df1b8d16f9d361861d2c4c50dcee" +checksum = "9a562c031f6c1f9d22f7bbcfbe715840d0e2cfe5e59d1296cc26000c17130555" dependencies = [ "derive-deftly", "derive_more", "serde", - "thiserror 2.0.18", + "thiserror 2.0.21", "tor-memquota", ] @@ -5232,11 +5424,11 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.11" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c" dependencies = [ - "bitflags 2.12.1", + "bitflags 2.13.2", "bytes", "futures-core", "futures-util", @@ -5288,7 +5480,7 @@ checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5348,7 +5540,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ad06847b7afb65c7866a36664b75c40b895e318cea4f71299f013fb22965329d" dependencies = [ "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5384,9 +5576,9 @@ checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" [[package]] name = "unicode-ident" -version = "1.0.24" +version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" [[package]] name = "unicode-segmentation" @@ -5400,6 +5592,16 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f4987bdc12753382e0bec4a65c50738ffaabc998b9cdd1f952fb5f39b0048a96" +dependencies = [ + "crypto-common 0.2.2", + "ctutils", +] + [[package]] name = "untrusted" version = "0.9.0" @@ -5414,11 +5616,11 @@ checksum = "6d49784317cd0d1ee7ec5c716dd598ec5b4483ea832a2dced265471cc0f690ae" [[package]] name = "ureq" -version = "3.3.0" +version = "3.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dea7109cdcd5864d4eeb1b58a1648dc9bf520360d7af16ec26d0a9354bafcfc0" +checksum = "9a7ac20be9b7726e0bbdbf974c059676d9acb1cd414961f570a4e8231cacd7fc" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "log", "percent-encoding", "rustls", @@ -5430,11 +5632,11 @@ dependencies = [ [[package]] name = "ureq-proto" -version = "0.6.0" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e994ba84b0bd1b1b0cf92878b7ef898a5c1760108fe7b6010327e274917a808c" +checksum = "f86fd172ccca569e458f61b6bdd6220965a9ef36e672a6852953b51a0e1583be" dependencies = [ - "base64 0.22.1", + "base64 0.23.1", "http", "httparse", "log", @@ -5452,12 +5654,6 @@ dependencies = [ "serde", ] -[[package]] -name = "utf8-width" -version = "0.1.8" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1292c0d970b54115d14f2492fe0170adf21d68a1de108eebc51c1df4f346a091" - [[package]] name = "utf8-zero" version = "0.8.1" @@ -5478,11 +5674,11 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.2" +version = "1.26.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" +checksum = "2ef6dac1e96601b4fb3acccccff2139741fcb757cb9a36089bf5be91cfb285ce" dependencies = [ - "getrandom 0.4.2", + "getrandom 0.4.3", "js-sys", "serde_core", "wasm-bindgen", @@ -5514,7 +5710,7 @@ checksum = "d674d135b4a8c1d7e813e2f8d1c9a58308aee4a680323066025e53132218bd91" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5541,36 +5737,27 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasip2" -version = "1.0.3+wasi-0.2.9" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" -dependencies = [ - "wit-bindgen 0.57.1", -] - -[[package]] -name = "wasip3" -version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +version = "1.0.4+wasi-0.2.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +checksum = "b67efb37e106e55ce722a510d6b5f9c17f083e5fc79afc2badeb12cc313d9487" dependencies = [ - "wit-bindgen 0.51.0", + "wit-bindgen", ] [[package]] name = "wasix" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1757e0d1f8456693c7e5c6c629bdb54884e032aa0bb53c155f6a39f94440d332" +checksum = "ae86f02046da16a333a9129d31451423e1657737ecdafed4193838a5f54c5cfe" dependencies = [ "wasi", ] [[package]] name = "wasm-bindgen" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ed04576f974d2b2fba0f38c51dbc5518011e38c36bf1143164be765528fd409" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" dependencies = [ "cfg-if", "once_cell", @@ -5581,9 +5768,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "916151b09da36bd82f6615cbf3a419e2f0ba23a03c6160e8e92eb6bd4aa1dec6" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5591,66 +5778,36 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "299047362ccbfce148b67ab7e73349f77748e00c8296f9542adfad2ad82c5c5e" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", "wasm-bindgen-shared", ] [[package]] name = "wasm-bindgen-shared" -version = "0.2.122" +version = "0.2.129" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a929b2c61f11ba3e9bc35b50c1f25cb38e0e892c0c231ae2b8cf78d5dad4437" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" dependencies = [ "unicode-ident", ] [[package]] -name = "wasm-encoder" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" -dependencies = [ - "leb128fmt", - "wasmparser", -] - -[[package]] -name = "wasm-metadata" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" -dependencies = [ - "anyhow", - "indexmap 2.14.0", - "wasm-encoder", - "wasmparser", -] - -[[package]] -name = "wasmparser" -version = "0.244.0" +name = "weak-table" +version = "0.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +checksum = "5eba2ec791141b0bf4db542f6b038a8f9310cee33e076cb181245780b09a24ff" dependencies = [ - "bitflags 2.12.1", - "hashbrown 0.15.5", - "indexmap 2.14.0", - "semver", + "cfg-if", + "hashbrown 0.16.1", ] -[[package]] -name = "weak-table" -version = "0.3.2" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" - [[package]] name = "web-time" version = "1.1.0" @@ -5663,18 +5820,18 @@ dependencies = [ [[package]] name = "web-time-compat" -version = "0.1.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39819265f219f60a92312f2755262dba9fff180a4ec281556863d69fa36adc59" +checksum = "b6ab47578e1cd38415489592da29326341e272bd281f4ddbc0c10b654a425ad7" dependencies = [ "web-time", ] [[package]] name = "webpki-roots" -version = "1.0.7" +version = "1.0.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52f5ee44c96cf55f1b349600768e3ece3a8f26010c05265ab73f945bb1a2eb9d" +checksum = "7dcd9d09a39985f5344844e66b0c530a33843579125f23e21e9f0f220850f22a" dependencies = [ "rustls-pki-types", ] @@ -5763,7 +5920,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5774,7 +5931,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -5820,6 +5977,15 @@ dependencies = [ "windows-targets 0.52.6", ] +[[package]] +name = "windows-sys" +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" @@ -5987,112 +6153,24 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0592e1c9d151f854e6fd382574c3a0855250e1d9b2f99d9281c6e6391af352f1" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" dependencies = [ "memchr", ] -[[package]] -name = "wit-bindgen" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" -dependencies = [ - "wit-bindgen-rust-macro", -] - [[package]] name = "wit-bindgen" version = "0.57.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" -[[package]] -name = "wit-bindgen-core" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" -dependencies = [ - "anyhow", - "heck", - "wit-parser", -] - -[[package]] -name = "wit-bindgen-rust" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" -dependencies = [ - "anyhow", - "heck", - "indexmap 2.14.0", - "prettyplease", - "syn 2.0.117", - "wasm-metadata", - "wit-bindgen-core", - "wit-component", -] - -[[package]] -name = "wit-bindgen-rust-macro" -version = "0.51.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" -dependencies = [ - "anyhow", - "prettyplease", - "proc-macro2", - "quote", - "syn 2.0.117", - "wit-bindgen-core", - "wit-bindgen-rust", -] - -[[package]] -name = "wit-component" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" -dependencies = [ - "anyhow", - "bitflags 2.12.1", - "indexmap 2.14.0", - "log", - "serde", - "serde_derive", - "serde_json", - "wasm-encoder", - "wasm-metadata", - "wasmparser", - "wit-parser", -] - -[[package]] -name = "wit-parser" -version = "0.244.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" -dependencies = [ - "anyhow", - "id-arena", - "indexmap 2.14.0", - "log", - "semver", - "serde", - "serde_derive", - "serde_json", - "unicode-xid", - "wasmparser", -] - [[package]] name = "writeable" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" +checksum = "3ad82d2a33cdc9674dc7465672f271e096168fcdbe0f799d9e6db8c5892679dc" [[package]] name = "wyz" @@ -6127,9 +6205,9 @@ dependencies = [ [[package]] name = "xxhash-rust" -version = "0.8.15" +version = "0.8.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fdd20c5420375476fbd4394763288da7eb0cc0b8c11deed431a91562af7335d3" +checksum = "550a2b930b62486a393c52d5c3b84bff264b28aa437ed64694d31e93b1757af7" [[package]] name = "yoke" @@ -6144,34 +6222,34 @@ dependencies = [ [[package]] name = "yoke-derive" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +checksum = "33811428bee40dbceb6d545e95754741d17a6aef9a4849f0fd62e2ba4f412a78" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zerocopy" -version = "0.8.50" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" +checksum = "6df92bf3d9227be3d53173901ddbffac2babc27ae50f397776ffd6dc33f800cb" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.50" +version = "0.8.59" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" +checksum = "ac4f328cf2f05d084e496c3e9c3f33ed0a183656a16e1fcec4d464d8373aec82" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] @@ -6185,41 +6263,41 @@ dependencies = [ [[package]] name = "zerofrom-derive" -version = "0.1.7" +version = "0.1.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +checksum = "f75b4683f6c7f45248d4d64056a24298c6281e0993356d7d1b4a1a962ef10d4a" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", - "synstructure", + "syn 3.0.6", + "synstructure 0.14.0", ] [[package]] name = "zeroize" -version = "1.8.2" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" +checksum = "e13c156562582aa81c60cb29407084cdb54c4164760106ab78e6c5b0858cf64e" dependencies = [ "zeroize_derive", ] [[package]] name = "zeroize_derive" -version = "1.4.3" +version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "85a5b4158499876c763cb03bc4e49185d3cccbabb15b33c627f7884f43db852e" +checksum = "3c50655cbb0fe3fc43170059e702f1ce5e19b84cec58dc87b037a09935c2f328" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 2.0.119", ] [[package]] name = "zerotrie" -version = "0.2.4" +version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +checksum = "4ea269c3bd32f0a32c321907a2ae912ba6f4649bb0fc764a15627e99a7095a3f" dependencies = [ "displaydoc", "yoke", @@ -6228,9 +6306,9 @@ dependencies = [ [[package]] name = "zerovec" -version = "0.11.6" +version = "0.11.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +checksum = "bb0464e17806c1d976d5cba29399c7f08e516e279e2ba493f63123b5fca67dd8" dependencies = [ "serde", "yoke", @@ -6240,23 +6318,23 @@ dependencies = [ [[package]] name = "zerovec-derive" -version = "0.11.3" +version = "0.11.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +checksum = "34df6fc39dbd26ddc9c10e6a2984476e13acce22e64e4487636ef494369225da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.6", ] [[package]] name = "zlib-rs" -version = "0.6.3" +version = "0.6.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3be3d40e40a133f9c916ee3f9f4fa2d9d63435b5fbe1bfc6d9dae0aa0ada1513" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" [[package]] name = "zmij" -version = "1.0.21" +version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" diff --git a/Cargo.toml b/Cargo.toml index 45635d5..d809062 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,11 +1,11 @@ [package] name = "rustpost" -version = "0.2.0" +version = "1.0.0" edition = "2024" license = "MIT" description = "A single-binary self-hosted microblogging app" repository = "https://github.com/csd113/RustPost" -rust-version = "1.90" +rust-version = "1.91" keywords = ["microblog", "self-hosted", "sqlite", "axum"] categories = ["web-programming::http-server"] autobins = false @@ -15,43 +15,43 @@ name = "rustpost-cli" path = "src/bin/rustpost-cli.rs" [dependencies] -anyhow = "1.0" -arti-client = { version = "=0.42.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } -argon2 = "0.5" +anyhow = "1.0.104" +arti-client = { version = "=0.46.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } +argon2 = "0.5.3" axum = { version = "0.8.9", features = ["multipart"] } -base64 = "0.22" +base64 = "0.23.1" captcha = { version = "1.0", default-features = false } -chrono = { version = "0.4", features = ["serde"] } -clap = { version = "4.5", features = ["derive", "env"] } -futures-util = "0.3" -flate2 = "1.1" -html-escape = "0.2" -infer = "0.19" -rand_core = { version = "0.6", features = ["getrandom"] } -safelog = "0.8" -serde = { version = "1.0", features = ["derive"] } -serde_json = "1.0" -sha2 = "0.10" -rusqlite = { version = "0.39", features = ["bundled"] } -rustls = { version = "0.23", default-features = false, features = ["ring"] } -tar = "0.4" -tempfile = "3.23" -thiserror = "2.0" -tokio = { version = "1.48", features = ["macros", "rt-multi-thread", "fs", "process", "signal", "net", "time", "io-util", "sync"] } -toml = "0.9" -tor-cell = "=0.42.0" -tor-hsservice = "=0.42.0" -tor-proto = { version = "=0.42.0", features = ["tokio"] } -tor-rtcompat = { version = "=0.42.0", default-features = false, features = ["tokio", "rustls"] } -tower-http = { version = "0.6", features = ["fs", "trace"] } -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter"] } -ureq = { version = "3.3", default-features = false, features = ["rustls"] } -uuid = { version = "1.18", features = ["v4", "serde"] } +chrono = { version = "0.4.45", features = ["serde"] } +clap = { version = "4.6.6", features = ["derive", "env"] } +futures-util = "0.3.34" +flate2 = "1.1.9" +html-escape = "0.2.15" +infer = "0.22" +getrandom = "0.4.3" +safelog = "0.9.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"] } +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"] } +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"] } walkdir = "2.5" [dev-dependencies] -http-body-util = "0.1" +http-body-util = "0.1.5" [profile.release] lto = true diff --git a/README.md b/README.md index 7c86fb7..f21bdc2 100644 --- a/README.md +++ b/README.md @@ -14,13 +14,15 @@ **A single-binary, self-hosted microblog — yours alone, with no cloud required.** [![CI](https://img.shields.io/github/actions/workflow/status/csd113/RustPost/ci.yml?branch=main&style=flat-square&label=CI&logo=github)](https://github.com/csd113/RustPost/actions) -[![Rust](https://img.shields.io/badge/rust-1.90%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) +[![Rust](https://img.shields.io/badge/rust-1.91%2B-orange?style=flat-square&logo=rust)](https://www.rust-lang.org/) [![SQLite](https://img.shields.io/badge/database-SQLite-blue?style=flat-square&logo=sqlite)](https://www.sqlite.org/) -[![Tor Ready](https://img.shields.io/badge/Tor-Arti%200.42.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) +[![Embedded Arti](https://img.shields.io/badge/embedded%20Arti-0.46.0-7D4698?style=flat-square&logo=torproject)](https://www.torproject.org/) [![License](https://img.shields.io/badge/license-see%20LICENSE-green?style=flat-square)](./LICENSE) [**Getting Started**](#-getting-started) · [**Configuration**](#-configuration) · [**CLI Reference**](#-cli-reference) · [**Security**](#-security-model) · [**Tor / Arti**](#-tor--arti) +**First stable release:** [v1.0.0](https://github.com/csd113/RustPost/releases/tag/v1.0.0) · [Changelog](CHANGELOG.md) + --- @@ -31,7 +33,7 @@ RustPost is a **single-binary, self-hosted microblogging platform** written in R > **Design philosophy:** Small, auditable modules over framework magic. Security-sensitive behavior centralized. No JavaScript bundler. No cloud dependency. No federation surface. -This repository is a **production-oriented project** — not a toy, but not overengineered. The goal is a system you can read end to end, deploy in minutes, and trust. +This repository is intended for operators who want a small, understandable self-hosted service. Operators remain responsible for deployment hardening, monitoring, backups, upgrades, and deciding whether RustPost is appropriate for their environment. --- @@ -44,6 +46,10 @@ This repository is a **production-oriented project** — not a toy, but not over | Sessions | Server-side, HttpOnly, SameSite=Lax cookies | | Profile customization | Bio, avatar, and banner with WebP conversion | | Admin CLI | Create admins and reset passwords from the command line | +| Username changes | Users can change their @handle; previous handles stay reserved to prevent impersonation, with history shown on profiles | +| Account deletion | Deletion starts a configurable grace period with a visible deadline, cancellation, and restart-safe finalization | +| Account export/import | Versioned `.tar.gz` archives with posts, profile, media bytes, media list, outgoing follows, and settings | +| Admin account controls | Forced password reset on next login and forced logout of every session | ### Social Features | Feature | Details | @@ -52,11 +58,25 @@ This repository is a **production-oriented project** — not a toy, but not over | Replies & Threads | Threaded conversations with full timeline rendering | | Reposts | First-class timeline events; deleted originals render gracefully | | Follows, Blocks & Mutes | Standard social graph primitives | +| Protected accounts | Optional approval for new followers: pending requests never count as followers until approved | | Likes & Bookmarks | Likes are public; bookmarks are private | | Notifications | In-app notification feed | +| Muted words | Per-user phrase list applied server-side | | Search | Full-text search via SQLite FTS5 + user matching | | Anonymous posting | Supported but **disabled by default** | +### Blocking, muting, and muted words + +RustPost enforces these rules in the database and query layer, not only in the UI. + +| 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. | + +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. + ### Media Pipeline | Feature | Details | |---|---| @@ -69,11 +89,13 @@ This repository is a **production-oriented project** — not a toy, but not over ### Operations | Feature | Details | |---|---| -| Database | SQLite with WAL, foreign keys, a release schema baseline, FTS5, and timeline indexes | +| Database | SQLite with WAL, foreign keys, schema version 4, FTS5, and timeline indexes | | Rate limiting | SQLite-backed per-user and per-IP limits for all write operations | | Backups | Deterministic tar archive with manifest, hashes, DB snapshot, settings, media, assets, and optional Tor keys | | Restore | Staged manifest/hash/SQLite/settings validation before runtime file swaps | | Admin dashboard | Site health, users, media jobs, conversion state, and backup management | +| Announcements | Admin-controlled banner rendered next to the site name in the top bar | +| Maintenance mode | Admin toggle that disables posting and registration while the site stays readable | | HTTP compression | Browser text responses use gzip when requested; media and binary uploads are intentionally left uncompressed | | Tor / Arti | Embedded onion-service startup — clearnet-only, Tor-only, or dual mode | @@ -103,30 +125,46 @@ The screenshots above were captured from a local generated demo instance with fi ### Prerequisites -- **Rust 1.90+** — install via [rustup](https://rustup.rs/) +- **Rust 1.91+** — install via [rustup](https://rustup.rs/) - **ffmpeg** *(optional)* — enables image and video conversion. RustPost boots and runs without it. -### Build +### Build from source ```sh cargo build --release ``` -This produces two binaries in `target/release/`: +This produces the release binary in `target/release/`: | Binary | Purpose | |---|---| -| `rustpost` | Primary server + CLI | -| `rustpost-cli` | Identical CLI surface — useful for running admin commands while the server is managed separately | +| `rustpost-cli` | Server and administration CLI | + +### Install a release archive + +Download the archive and matching `.sha256` file for your platform from the [v1.0.0 GitHub release](https://github.com/csd113/RustPost/releases/tag/v1.0.0), verify the checksum, then extract it. Unix archives contain `rustpost/rustpost-cli`; the Windows archive contains `rustpost/rustpost-cli.exe`. + +Example Unix user-local install: + +```sh +# Linux checksum verification +sha256sum -c rustpost-linux-x86_64.tar.gz.sha256 +# macOS checksum verification uses: shasum -a 256 -c .sha256 +tar -xzf rustpost-linux-x86_64.tar.gz +install -m 0755 rustpost/rustpost-cli "$HOME/.local/bin/rustpost-cli" +rustpost-cli --version +``` + +Use an explicit `--data-dir` for installed deployments. Without it, RustPost places `rustpost-data` beside the executable. ### First Run ```sh # Linux / macOS -./target/release/rustpost --data-dir ./rustpost-data serve +./target/release/rustpost-cli --data-dir ./rustpost-data serve # Windows (PowerShell) -.\target\release\rustpost.exe --data-dir .\rustpost-data serve +.\target\release\rustpost-cli.exe --data-dir .\rustpost-data serve ``` > If no subcommand is provided, `serve` is assumed. @@ -145,6 +183,7 @@ rustpost-data/ │ ├── images/ │ ├── videos/ │ └── thumbs/ +├── assets/ ├── tmp/ │ └── uploads/ ← interrupted upload staging only ├── backups/ @@ -154,23 +193,23 @@ rustpost-data/ ``` > **Note:** All runtime paths are derived from `--data-dir` (or the executable location as fallback). RustPost does not rely on the current working directory. -> Runtime data is local operator state. Databases, uploads, logs, backups, Tor key material, and temporary upload files under `rustpost-data/` should not be committed to git. +> Runtime data is local operator state. Databases, uploads, logs, backups, Tor key material, and temporary upload files under `rustpost-data/` should not be committed to git. On Unix, RustPost restricts the runtime data directory to mode `0700`. > Existing data directories that still contain `app.sqlite3` at the data-dir root are migrated to `db/rustpost.sqlite3` on startup. If both old and new database files exist, RustPost stops with a conflict error and does not overwrite either file. ### Create Your First Admin -When `serve` starts, RustPost prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, and whether an admin account exists. If `admin.create_admin_on_first_boot` is enabled and no admin exists, interactive startup enters a `Create admin account` step before the server begins accepting requests. If stdin is not interactive, RustPost prints the bootstrap commands instead of waiting for input. +If `admin.create_admin_on_first_boot` is enabled and no admin exists, interactive `serve` startup enters a `Create admin account` step before the server begins accepting requests. If stdin is not interactive, RustPost prints the bootstrap commands instead of waiting for input. Startup then prints the data directory, settings path, database path, upload/media paths, log path, backup path, bind address, local URL, and whether an admin account exists. You can also create the first admin explicitly. The preferred local setup path hides the password while you type: ```sh -./target/release/rustpost --data-dir ./rustpost-data create-admin-interactive +./target/release/rustpost-cli --data-dir ./rustpost-data create-admin-interactive ``` -For scripted deployments, the non-interactive command is still available. Be aware that command-line arguments can be visible to other local processes on some systems: +For scripted deployments, the non-interactive command is still available. After setting `RUSTPOST_ADMIN_PASSWORD` from the deployment's secret source, run the following. Be aware that command-line arguments can be visible to other local processes on some systems: ```sh -./target/release/rustpost --data-dir ./rustpost-data create-admin alice s3cr3tpassword +./target/release/rustpost-cli --data-dir ./rustpost-data create-admin alice "$RUSTPOST_ADMIN_PASSWORD" ``` Then open [http://127.0.0.1:8080](http://127.0.0.1:8080) and log in. @@ -188,18 +227,17 @@ Replies remain attached to their parent thread. They render inside the thread vi ## 🖥 CLI Reference ```sh -rustpost init # Initialize data directory -rustpost check # Validate config, data directory, and DB schema status -rustpost create-admin # Create an admin account -rustpost create-admin-interactive # Create an admin with hidden password prompts -rustpost reset-admin-password # Reset an admin's password -rustpost seed-demo # Seed a guarded local demo instance under target/debug/rustpost-demo -rustpost serve # Start the HTTP server (default) -rustpost backup # Create a backup archive -rustpost backup --include-tor-keys # Backup including Tor private keys -rustpost restore # Restore from a backup -rustpost restore --include-tor-keys # Restore including Tor keys -rustpost print-onion-address # Print the current .onion hostname +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 seed-demo # Seed a guarded local demo instance under target/debug/rustpost-demo +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 ``` --- @@ -217,7 +255,7 @@ The visible site name is configured in `settings.toml`: name = "RustPost" ``` -Changing `site.name` updates the rendered browser title, header brand, footer, and user-facing site copy. Binary names, package names, cookie names, and data paths remain `rustpost` for compatibility. +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 @@ -229,6 +267,22 @@ registration_captcha_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 [docs/account-and-instance-features.md](docs/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 [docs/account-and-instance-features.md](docs/account-and-instance-features.md) for the full behavior and policy details. + ### Post editing ```toml @@ -321,6 +375,9 @@ anonymous_posts_per_ip_per_hour = 10 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 | @@ -329,18 +386,18 @@ RustPost embeds [Arti](https://gitlab.torproject.org/tpo/core/arti) (the Rust To | `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. | -**Arti crate versions (Arti 2.3.0 / 2026-05-07):** +**Current pinned Arti/Tor crates in `Cargo.toml`:** ``` -arti-client = 0.42.0 # bootstraps the embedded Tor client and onion service -tor-hsservice = 0.42.0 # onion-service config, handle, and rendezvous streams -tor-proto = 0.42.0 # inspect and accept incoming onion stream requests -tor-cell = 0.42.0 # cell-level protocol handling -tor-rtcompat = 0.42.0 # Tokio-compatible Arti runtime +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` rather than `sqlx 0.9` (alpha) specifically to keep the `libsqlite3-sys` dependency unified with the Arti family. +> **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. **Tor data layout:** @@ -354,14 +411,38 @@ rustpost-data/tor/ **Backups and Tor keys:** ```sh -rustpost backup # excludes Tor keys (safe default) -rustpost backup --include-tor-keys # opt-in to include keys -rustpost restore archive.tar # rejects Tor key paths unless flag given -rustpost restore archive.tar --include-tor-keys +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 ``` +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. +**Live/local Tor smoke validation:** + +- Start dual mode with a fresh explicit `--data-dir`, then confirm Arti bootstrap and onion descriptor publication in the logs. +- Valid public local smoke endpoints are `/`, `/home`, `/login`, and `/register`. RustPost does not implement `/healthz` or `/readyz`; use the startup/status output and authenticated `/admin/health` page for operational status. +- 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`: + +```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/" +``` + +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. + --- ## 🎬 Media & FFmpeg @@ -447,12 +528,12 @@ Run the app locally with a disposable data directory: ```sh cargo build --workspace --all-features -./target/debug/rustpost --data-dir /tmp/rustpost-ui serve +./target/debug/rustpost-cli --data-dir /tmp/rustpost-ui serve ``` Then open [http://127.0.0.1:8080](http://127.0.0.1:8080). The server initializes the data directory on first boot. -**CI matrix** (GitHub Actions, Rust 1.90): +**CI matrix** (GitHub Actions, Rust 1.91): | Platform | Arch | |---|---| @@ -465,7 +546,7 @@ CI runs: format check → Clippy → tests → release build. A separate strict ### Release Artifacts -Tagged releases matching `v*` produce: +The `v1.0.0` tag triggers release builds for: ``` rustpost-linux-x86_64.tar.gz @@ -474,7 +555,7 @@ rustpost-macos-aarch64.tar.gz rustpost-windows-x86_64.zip ``` -Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and optional notice files. A `.sha256` checksum is generated for each archive. Runtime data (databases, uploads, backups, logs, Tor keys) is never included. +Each archive contains `rustpost-cli` (`rustpost-cli.exe` on Windows), `README.md`, `LICENSE`, and optional notice files. A `.sha256` checksum is generated for each archive. Runtime data (databases, uploads, backups, logs, Tor keys) is never included. --- @@ -486,7 +567,7 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op | `tokio` | Async runtime, filesystem, process, and signals | | `rusqlite` | SQLite access via a dedicated DB worker; WAL, FK, baseline schema plus forward migrations | | `argon2` | Argon2id password hashing | -| `rand_core` + `uuid` | Secure salts and opaque generated tokens/filenames | +| `getrandom` + `uuid` | OS-generated password salts and opaque generated tokens/filenames | | `infer` | Content-based media type detection | | `flate2` | Gzip response compression for browser text responses | | `tower-http` | Static upload serving and HTTP tracing | @@ -504,7 +585,23 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op ## ✅ Release Verification -*Last sweep: **May 18, 2026*** +*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: + +```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 +``` + +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.
Verified locally @@ -512,22 +609,21 @@ Each archive contains `rustpost`, `rustpost-cli`, `README.md`, `LICENSE`, and op - 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, login, post creation, replies, repost rendering, likes, bookmarks, follows, notifications, admin health, and CSRF-protected logout all work through live HTTP requests. +- 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. Restore into a fresh data directory completed and `check` passed. +- 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_only = false` live-tested: clearnet bound quickly, Arti produced a real `.onion` hostname, Tor Browser reached `/home` through the onion. -- `tor_only = true` live-tested: no clearnet listener exposed, loopback-only binding confirmed, real `.onion` hostname produced and reached via Tor Browser. -- Both `rustpost` and `rustpost-cli` pass `check` on a fresh data directory. +- 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 -- Live Tor reachability depends on Tor network access and descriptor publication time. The May 2026 sweep succeeded with a temporary onion identity; future release checks should repeat with a non-temporary identity if a stable onion address is required. +- 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.
diff --git a/docs/account-and-instance-features.md b/docs/account-and-instance-features.md new file mode 100644 index 0000000..6335a24 --- /dev/null +++ b/docs/account-and-instance-features.md @@ -0,0 +1,288 @@ +# Account lifecycle, social approval, and instance controls + +This document describes the behavior added for: + +- [#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 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. + +## 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 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. + +## 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 (`/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. diff --git a/src/account.rs b/src/account.rs index 5b45085..78f9c7c 100644 --- a/src/account.rs +++ b/src/account.rs @@ -1,13 +1,27 @@ use std::collections::BTreeSet; use std::fs; +use std::io::Write as _; use std::path::{Component, Path, PathBuf}; +use anyhow::Context as _; use rusqlite::{OptionalExtension as _, params}; +use uuid::Uuid; use crate::auth; +use crate::config::Settings; use crate::db::SqlitePool; use crate::runtime::RuntimePaths; +/// File-name prefix for durable pending-media-deletion journals. +/// +/// The runtime temp cleanup never matches this prefix, so a pending journal is +/// never removed behind the account deletion path's back. +const PENDING_MEDIA_DELETION_PREFIX: &str = "pending-media-deletion-"; +/// File-name suffix for durable pending-media-deletion journals. +const PENDING_MEDIA_DELETION_SUFFIX: &str = ".journal"; +/// Maximum pending-media-deletion journals recovered in one pass. +const MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS: usize = 1000; + #[derive(Debug, Clone, PartialEq, Eq)] pub enum DeleteAccountError { WrongPassword, @@ -59,14 +73,200 @@ pub async fn delete_account( return Err(DeleteAccountError::WrongPassword); } - let media_files = scrub_account_rows(pool, user_id, paths).await?; + let outcome = scrub_account_rows(pool, user_id, paths).await?; - let deleted_media_files = remove_media_files(&media_files, user_id); + let deleted_media_files = finish_media_cleanup(&outcome, user_id); Ok(AccountDeletionSummary { deleted_media_files, }) } +/// A pending account deletion. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DeletionRequest { + /// When the deletion was requested. + pub requested_at: String, + /// When the account becomes eligible for permanent removal. + pub scheduled_at: String, +} + +/// Records a deletion request and the final deadline. +/// +/// The account keeps authenticating while the deadline is in the future and +/// [`cancel_deletion`] clears the request. A configured grace period of zero +/// schedules the deadline immediately. +pub async fn request_deletion( + pool: &SqlitePool, + settings: &Settings, + user_id: i64, +) -> anyhow::Result { + let grace_days = settings.accounts.deletion_grace_period_days; + let modifier = format!("+{grace_days} days"); + pool.call(move |conn| { + let tx = conn.transaction()?; + let existing = tx + .query_row( + "SELECT deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ? AND is_deleted = 0", + [user_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, Option>(1)?, + )) + }, + ) + .optional()?; + let Some((requested_at, scheduled_at)) = existing else { + anyhow::bail!("account not found"); + }; + if let (Some(requested_at), Some(scheduled_at)) = (requested_at, scheduled_at) { + tx.commit()?; + return Ok(DeletionRequest { + requested_at, + scheduled_at, + }); + } + tx.execute( + "UPDATE users SET deletion_requested_at = datetime('now'), deletion_scheduled_at = datetime('now', ?1), updated_at = CURRENT_TIMESTAMP WHERE id = ?", + params![modifier, user_id], + )?; + let (requested_at, scheduled_at) = tx.query_row( + "SELECT deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ?", + [user_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + )?; + tx.commit()?; + Ok(DeletionRequest { + requested_at, + scheduled_at, + }) + }) + .await +} + +/// Clears a pending deletion request. Returns `true` when a request existed. +/// +/// Cancellation is a no-op for accounts that are already permanently removed or +/// marked deleted, so it can never resurrect a deleted account. +pub async fn cancel_deletion(pool: &SqlitePool, user_id: i64) -> anyhow::Result { + pool.call(move |conn| { + let changed = conn.execute( + "UPDATE users SET deletion_requested_at = NULL, deletion_scheduled_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0 AND deletion_requested_at IS NOT NULL", + [user_id], + )?; + Ok(changed > 0) + }) + .await +} + +/// Reads the pending deletion window for an account, if any. +pub async fn deletion_status( + pool: &SqlitePool, + user_id: i64, +) -> anyhow::Result> { + pool.call(move |conn| { + conn.query_row( + "SELECT deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ? AND is_deleted = 0 AND deletion_requested_at IS NOT NULL AND deletion_scheduled_at IS NOT NULL", + [user_id], + |row| { + Ok(DeletionRequest { + requested_at: row.get(0)?, + scheduled_at: row.get(1)?, + }) + }, + ) + .optional() + .map_err(Into::into) + }) + .await +} + +/// Permanently removes every account whose deletion deadline has passed. +/// +/// Runs the same scrubbing logic as [`delete_account`] without a password +/// check, is safe to call repeatedly, and is driven by persisted timestamps so +/// it works after restarts. Failures for one account are logged and retried on +/// the next call; the returned count is the number of accounts fully removed. +pub async fn finalize_due_deletions( + pool: &SqlitePool, + paths: &RuntimePaths, +) -> anyhow::Result { + let due_ids = pool + .call(|conn| { + let mut stmt = conn.prepare( + "SELECT id FROM users WHERE is_deleted = 0 AND deletion_scheduled_at IS NOT NULL AND datetime(deletion_scheduled_at) <= CURRENT_TIMESTAMP ORDER BY id", + )?; + let ids = stmt + .query_map([], |row| row.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(ids) + }) + .await?; + let mut removed = 0; + for user_id in due_ids { + match finalize_due_deletion(pool, paths, user_id).await { + Ok(true) => removed += 1, + Ok(false) => {} + Err(error) => { + tracing::warn!( + user_id, + error = %error, + "failed to finalize due account deletion; will retry on the next pass" + ); + } + } + } + Ok(removed) +} + +/// Finalizes one due deletion, re-checking the persisted deadline and request +/// marker inside the scrubbing transaction so a concurrent cancellation wins. +/// +/// Returns `true` when the account was removed in this call. Two concurrent +/// callers can never both claim the same account: the claim and the row +/// deletion share one transaction, and the loser observes no due row. +async fn finalize_due_deletion( + pool: &SqlitePool, + paths: &RuntimePaths, + user_id: i64, +) -> anyhow::Result { + let paths = paths.clone(); + let result = pool + .call(move |conn| { + let result: Result, DeleteAccountError> = (|| { + let tx = conn.transaction()?; + let due = tx + .query_row( + "SELECT 1 FROM users WHERE id = ? AND is_deleted = 0 AND deletion_requested_at IS NOT NULL AND deletion_scheduled_at IS NOT NULL AND datetime(deletion_scheduled_at) <= CURRENT_TIMESTAMP", + [user_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !due { + tx.commit()?; + return Ok(None); + } + let outcome = scrub_account_rows_in_tx(&tx, user_id, &paths)?; + commit_scrub_transaction(tx, &outcome, user_id)?; + Ok(Some(outcome)) + })(); + Ok(result) + }) + .await?; + let result = result?; + let Some(outcome) = result else { + return Ok(false); + }; + let deleted_media_files = finish_media_cleanup(&outcome, user_id); + tracing::info!( + user_id, + deleted_media_files, + "finalized scheduled account deletion" + ); + Ok(true) +} + #[derive(Debug, Clone)] struct AccountMedia { id: i64, @@ -75,6 +275,33 @@ struct AccountMedia { thumbnail_path: Option, } +/// Collects every normalized handle the account ever held so release +/// tombstones can be recorded in the deletion transaction. +/// +/// The current handle and the full username history are both included; the +/// caller records them before deleting the rows that store them. +fn account_normalized_usernames_in_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, +) -> anyhow::Result> { + let mut usernames = BTreeSet::new(); + let current: Option = tx + .query_row( + "SELECT normalized_username FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + ) + .optional()?; + usernames.extend(current); + let mut stmt = + tx.prepare("SELECT normalized_username FROM username_history WHERE user_id = ?")?; + let history = stmt + .query_map([user_id], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + usernames.extend(history); + Ok(usernames.into_iter().collect()) +} + fn account_media_in_tx( tx: &rusqlite::Transaction<'_>, user_id: i64, @@ -109,96 +336,154 @@ async fn scrub_account_rows( pool: &SqlitePool, user_id: i64, paths: &RuntimePaths, -) -> Result, DeleteAccountError> { +) -> Result { let paths = paths.clone(); pool.call(move |conn| { - let result: Result, DeleteAccountError> = (|| { + let result: Result = (|| { let tx = conn.transaction()?; - tx.execute( + let outcome = scrub_account_rows_in_tx(&tx, user_id, &paths)?; + commit_scrub_transaction(tx, &outcome, user_id)?; + Ok(outcome) + })(); + Ok(result) + }) + .await + .map_err(|err| DeleteAccountError::Database(err.to_string()))? +} + +/// Files whose database rows were removed, plus the durable journal that +/// records them until every file is gone. +struct ScrubOutcome { + media_files: BTreeSet, + journal_path: PathBuf, +} + +/// Commits a scrub transaction. +/// +/// When the commit fails the database rolls back and the media rows still +/// exist, so the just-written pending-deletion journal is discarded: recovery +/// must never delete files that survived a rollback. +fn commit_scrub_transaction( + tx: rusqlite::Transaction<'_>, + outcome: &ScrubOutcome, + user_id: i64, +) -> Result<(), DeleteAccountError> { + if let Err(error) = tx.commit() { + remove_journal(&outcome.journal_path, user_id); + return Err(DeleteAccountError::Database(error.to_string())); + } + Ok(()) +} + +/// Scrubs every row owned by one account inside an existing transaction. +/// +/// Shared by the password-confirmed immediate deletion path and the scheduled +/// finalization path. The caller commits; any error rolls the transaction back. +/// Before returning, a pending-media-deletion journal is written and fsynced so +/// a crash between the commit and the file removal leaves a durable record. +/// A journal write failure aborts the scrub and rolls the transaction back. +/// +/// Documented policy: permanent deletion releases the account's username +/// reservations. Release tombstones are recorded in `instance_settings` before +/// the `username_history` rows are deleted in this transaction, so old profile +/// URLs keep explaining that the handle belonged to a deleted account while +/// every handle becomes available again. +fn scrub_account_rows_in_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, + paths: &RuntimePaths, +) -> Result { + tx.execute( "UPDATE users SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", [user_id], )?; - let media = account_media_in_tx(&tx, user_id)?; - let mut media_paths = Vec::new(); - for item in &media { - if let Some(original_path) = &item.original_path { - media_paths.push(original_path.clone()); - } - media_paths.push(item.stored_path.clone()); - if let Some(thumbnail_path) = &item.thumbnail_path { - media_paths.push(thumbnail_path.clone()); - } - } - let mut safe_paths = Vec::new(); - for path in &media_paths { - if let Some(safe_path) = safe_media_path(&paths, path)? { - safe_paths.push((path.clone(), safe_path)); - } - } - let media_ids = media.into_iter().map(|item| item.id).collect::>(); - tx.execute("DELETE FROM sessions WHERE user_id = ?", [user_id])?; - tx.execute("DELETE FROM muted_words WHERE user_id = ?", [user_id])?; - tx.execute( - "DELETE FROM rate_limit_events WHERE actor = ?", - [format!("user:{user_id}")], - )?; - tx.execute( - "DELETE FROM blocks WHERE blocker_id = ? OR blocked_id = ?", - params![user_id, user_id], - )?; - tx.execute( - "DELETE FROM mutes WHERE muter_id = ? OR muted_id = ?", - params![user_id, user_id], - )?; - tx.execute( - "DELETE FROM follows WHERE follower_id = ? OR followed_id = ?", - params![user_id, user_id], - )?; - tx.execute( + let media = account_media_in_tx(tx, user_id)?; + let mut media_paths = Vec::new(); + for item in &media { + if let Some(original_path) = &item.original_path { + media_paths.push(original_path.clone()); + } + media_paths.push(item.stored_path.clone()); + if let Some(thumbnail_path) = &item.thumbnail_path { + media_paths.push(thumbnail_path.clone()); + } + } + let mut safe_paths = Vec::new(); + for path in &media_paths { + if let Some(safe_path) = safe_media_path(paths, path)? { + safe_paths.push((path.clone(), safe_path)); + } + } + let media_ids = media.into_iter().map(|item| item.id).collect::>(); + tx.execute("DELETE FROM sessions WHERE user_id = ?", [user_id])?; + tx.execute("DELETE FROM muted_words WHERE user_id = ?", [user_id])?; + tx.execute( + "DELETE FROM rate_limit_events WHERE actor = ?", + [format!("user:{user_id}")], + )?; + tx.execute( + "DELETE FROM blocks WHERE blocker_id = ? OR blocked_id = ?", + params![user_id, user_id], + )?; + tx.execute( + "DELETE FROM mutes WHERE muter_id = ? OR muted_id = ?", + params![user_id, user_id], + )?; + tx.execute( + "DELETE FROM follows WHERE follower_id = ? OR followed_id = ?", + params![user_id, user_id], + )?; + tx.execute( + "DELETE FROM follow_requests WHERE requester_id = ? OR target_id = ?", + params![user_id, user_id], + )?; + let released_usernames = account_normalized_usernames_in_tx(tx, user_id)?; + tx.execute("DELETE FROM username_history WHERE user_id = ?", [user_id])?; + crate::instance::record_released_usernames_tx(tx, &released_usernames)?; + tx.execute("DELETE FROM account_imports WHERE user_id = ?", [user_id])?; + tx.execute( "DELETE FROM notifications WHERE user_id = ? OR actor_user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM reports WHERE reporter_user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM likes WHERE user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM bookmarks WHERE user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute( + tx.execute( "DELETE FROM reposts WHERE user_id = ? OR post_id IN (SELECT id FROM posts WHERE user_id = ?)", params![user_id, user_id], )?; - tx.execute("DELETE FROM posts WHERE user_id = ?", [user_id])?; - for media_id in media_ids { - promote_canonical_references(&tx, media_id)?; - tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; - tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; - } - let mut media_files = BTreeSet::new(); - for (raw_path, safe_path) in safe_paths { - let remaining: i64 = tx.query_row( + tx.execute("DELETE FROM posts WHERE user_id = ?", [user_id])?; + for media_id in media_ids { + promote_canonical_references(tx, media_id)?; + tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; + tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; + } + let mut media_files = BTreeSet::new(); + for (raw_path, safe_path) in safe_paths { + let remaining: i64 = tx.query_row( "SELECT COUNT(*) FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ?", params![raw_path, raw_path, raw_path], |row| row.get(0), )?; - if remaining == 0 { - media_files.insert(safe_path); - } - } - tx.execute("DELETE FROM users WHERE id = ?", [user_id])?; - tx.commit()?; - Ok(media_files) - })(); - Ok(result) + if remaining == 0 { + media_files.insert(safe_path); + } + } + tx.execute("DELETE FROM users WHERE id = ?", [user_id])?; + let journal_path = write_pending_media_deletion_journal(paths, user_id, &media_files)?; + Ok(ScrubOutcome { + media_files, + journal_path, }) - .await - .map_err(|err| DeleteAccountError::Database(err.to_string()))? } fn safe_media_path( @@ -212,17 +497,13 @@ fn safe_media_path( if !path.is_absolute() || has_parent_component(&path) { return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); } - if !allowed_media_roots(paths) - .iter() - .any(|root| path.starts_with(root)) - { - return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); - } + let roots = allowed_media_roots(paths); + let raw_under_roots = roots.iter().any(|root| path.starts_with(root)); if path.exists() { let canonical_path = path .canonicalize() .map_err(|err| DeleteAccountError::Database(err.to_string()))?; - let canonical_roots = allowed_media_roots(paths) + let canonical_roots = roots .iter() .map(|root| root.canonicalize()) .collect::, _>>() @@ -233,8 +514,21 @@ fn safe_media_path( { return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); } + // A canonical path can legitimately live outside the configured roots + // when a root's ancestor is a symlink (for example macOS `/var` versus + // `/private/var`). Accept it only when the raw path is under a + // configured root or already expressed in canonical root form, so a + // path that merely resolves into the uploads from elsewhere is still + // rejected. + let raw_under_canonical_roots = canonical_roots.iter().any(|root| path.starts_with(root)); + if !raw_under_roots && !raw_under_canonical_roots { + return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); + } return Ok(Some(canonical_path)); } + if !raw_under_roots { + return Err(DeleteAccountError::UnsafeMediaPath(raw_path.to_owned())); + } Ok(Some(path)) } @@ -281,13 +575,41 @@ fn promote_canonical_references( Ok(()) } -fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { - let mut deleted = 0; +/// Removes the files recorded by one scrub and disposes of its journal. +/// +/// The journal is removed only when every file was removed or was already +/// missing. Any failure keeps the journal so +/// [`recover_pending_media_deletions`] can retry the cleanup after a restart. +fn finish_media_cleanup(outcome: &ScrubOutcome, user_id: i64) -> usize { + let removal = remove_media_files(&outcome.media_files, user_id); + if removal.complete { + remove_journal(&outcome.journal_path, user_id); + } else { + tracing::warn!( + user_id, + journal = %outcome.journal_path.display(), + "media file removal incomplete; keeping pending media deletion journal for retry" + ); + } + removal.deleted +} + +struct MediaFileRemoval { + deleted: usize, + complete: bool, +} + +fn remove_media_files(paths: &BTreeSet, user_id: i64) -> MediaFileRemoval { + let mut deletion = MediaFileRemoval { + deleted: 0, + complete: true, + }; for path in paths { match fs::remove_file(path) { - Ok(()) => deleted += 1, + Ok(()) => deletion.deleted += 1, Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} Err(error) => { + deletion.complete = false; tracing::warn!( user_id, path = %path.display(), @@ -297,7 +619,262 @@ fn remove_media_files(paths: &BTreeSet, user_id: i64) -> usize { } } } - deleted + deletion +} + +fn remove_journal(journal_path: &Path, user_id: i64) { + match fs::remove_file(journal_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + tracing::warn!( + user_id, + journal = %journal_path.display(), + error = %error, + "failed to remove pending media deletion journal" + ); + } + } +} + +/// Writes and fsyncs a pending-media-deletion journal for one scrub. +/// +/// 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 +/// can trust the validation and re-check it. Callers must roll the database +/// transaction back when this fails. +fn write_pending_media_deletion_journal( + paths: &RuntimePaths, + user_id: i64, + media_files: &BTreeSet, +) -> Result { + let journal_path = paths.tmp_dir.join(format!( + "{PENDING_MEDIA_DELETION_PREFIX}{user_id}-{}{PENDING_MEDIA_DELETION_SUFFIX}", + Uuid::new_v4().simple() + )); + if let Err(error) = write_journal_contents(&journal_path, user_id, media_files) { + let _ = fs::remove_file(&journal_path); + return Err(DeleteAccountError::Database(format!( + "failed to write pending media deletion journal {}: {error}", + journal_path.display() + ))); + } + if let Err(error) = sync_directory(&paths.tmp_dir) { + tracing::debug!( + dir = %paths.tmp_dir.display(), + error = %error, + "failed to fsync directory after writing pending media deletion journal" + ); + } + Ok(journal_path) +} + +fn write_journal_contents( + journal_path: &Path, + user_id: i64, + media_files: &BTreeSet, +) -> anyhow::Result<()> { + if let Some(parent) = journal_path.parent() { + fs::create_dir_all(parent)?; + } + let mut contents = String::new(); + contents.push_str(&format!("{user_id}\n")); + for path in media_files { + let Some(raw) = path.to_str() else { + anyhow::bail!("media path is not valid UTF-8"); + }; + contents.push_str(raw); + contents.push('\n'); + } + let mut file = fs::File::create(journal_path)?; + file.write_all(contents.as_bytes())?; + file.sync_all()?; + Ok(()) +} + +/// Fsyncs a directory so a newly created or removed journal survives a crash. +fn sync_directory(path: &Path) -> std::io::Result<()> { + fs::File::open(path)?.sync_all() +} + +/// 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 +/// 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. +/// +/// Safe to call repeatedly, both at startup and from a periodic scheduler; a +/// pass that loses a race with an in-flight finalization still converges on the +/// same end state because removals are idempotent. +pub async fn recover_pending_media_deletions(paths: &RuntimePaths) -> anyhow::Result { + let paths = paths.clone(); + tokio::task::spawn_blocking(move || recover_pending_media_deletions_blocking(&paths)) + .await + .map_err(|error| anyhow::anyhow!("pending media deletion recovery task failed: {error}"))? +} + +fn recover_pending_media_deletions_blocking(paths: &RuntimePaths) -> anyhow::Result { + let entries = match fs::read_dir(&paths.tmp_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(error).context(format!( + "failed to read temp directory {}", + paths.tmp_dir.display() + )); + } + }; + let mut attempted = 0usize; + let mut recovered = 0usize; + for entry in entries { + if attempted >= MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS { + tracing::warn!( + limit = MAX_PENDING_MEDIA_DELETION_JOURNALS_PER_PASS, + "pending media deletion recovery stopped early; too many journals to process" + ); + break; + } + let entry = match entry { + Ok(entry) => entry, + Err(error) => { + tracing::warn!(error = %error, "failed to read temp directory entry"); + continue; + } + }; + let journal_path = entry.path(); + if !is_pending_media_deletion_journal(&journal_path) { + continue; + } + attempted += 1; + match recover_pending_media_deletion_journal(paths, &journal_path) { + Ok(true) => recovered += 1, + Ok(false) => {} + Err(error) => { + tracing::warn!( + journal = %journal_path.display(), + error = %error, + "failed to recover pending media deletion journal; keeping it for review" + ); + } + } + } + Ok(recovered) +} + +fn is_pending_media_deletion_journal(path: &Path) -> bool { + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + return false; + }; + name.starts_with(PENDING_MEDIA_DELETION_PREFIX) && name.ends_with(PENDING_MEDIA_DELETION_SUFFIX) +} + +/// Removes one journal's files and then the journal itself. +/// +/// Returns `Ok(true)` when the journal was fully processed and removed, and +/// `Ok(false)` when it was skipped or must be retained. Symlinked journal files +/// are never read, and every recorded path is re-validated before removal. +fn recover_pending_media_deletion_journal( + paths: &RuntimePaths, + journal_path: &Path, +) -> anyhow::Result { + let metadata = fs::symlink_metadata(journal_path)?; + if !metadata.file_type().is_file() { + tracing::warn!( + journal = %journal_path.display(), + "ignoring pending media deletion journal that is not a regular file" + ); + return Ok(false); + } + let contents = fs::read_to_string(journal_path)?; + let (user_id, media_files) = parse_pending_media_deletion_journal(paths, &contents) + .with_context(|| { + format!( + "malformed pending media deletion journal {}", + journal_path.display() + ) + })?; + let mut deleted = 0usize; + let mut complete = true; + for path in &media_files { + match fs::remove_file(path) { + Ok(()) => deleted += 1, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + complete = false; + tracing::warn!( + user_id, + path = %path.display(), + error = %error, + "failed to remove media file from pending deletion journal" + ); + } + } + } + if !complete { + return Ok(false); + } + match fs::remove_file(journal_path) { + Ok(()) => {} + Err(error) if error.kind() == std::io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to remove pending media deletion journal {}", + journal_path.display() + ) + }); + } + } + tracing::info!( + user_id, + deleted, + journal = %journal_path.display(), + "recovered pending media deletion journal" + ); + Ok(true) +} + +/// Parses a journal into its owner id and already-validated absolute paths. +/// +/// The first line must be the numeric owner id; structurally malformed +/// journals produce an error so the caller retains them. Individual unsafe +/// path lines are skipped with a warning instead, and a path that cannot be +/// validated because of a transient filesystem error also retains the journal. +fn parse_pending_media_deletion_journal( + paths: &RuntimePaths, + contents: &str, +) -> anyhow::Result<(i64, Vec)> { + let mut lines = contents.lines(); + let Some(user_id_line) = lines.next() else { + anyhow::bail!("journal is empty"); + }; + let user_id = user_id_line + .trim() + .parse::() + .with_context(|| format!("journal user id line is not an integer: {user_id_line:?}"))?; + let mut media_files = Vec::new(); + for line in lines { + let raw = line.trim(); + if raw.is_empty() { + continue; + } + match safe_media_path(paths, raw) { + Ok(Some(path)) => media_files.push(path), + Ok(None) => {} + Err(DeleteAccountError::UnsafeMediaPath(path)) => { + tracing::warn!( + path = %path, + "skipping unsafe media path in pending deletion journal" + ); + } + Err(error) => return Err(error.into()), + } + } + Ok((user_id, media_files)) } #[cfg(test)] @@ -608,4 +1185,993 @@ mod tests { .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 963393a..831a234 100644 --- a/src/admin.rs +++ b/src/admin.rs @@ -1188,7 +1188,7 @@ pub async fn reset_admin_password( password: &str, ) -> anyhow::Result<()> { crate::validation::validate_password(password, settings)?; - let hash = auth::hash_password(password)?; + let hash = auth::hash_password_async(password.to_owned()).await?; let username = username.trim().to_ascii_lowercase(); let changed = pool .call(move |conn| { diff --git a/src/auth.rs b/src/auth.rs index c8b5eaa..e8fcf3a 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,8 +1,8 @@ +use anyhow::Context as _; use argon2::password_hash::{PasswordHash, PasswordHasher as _, PasswordVerifier as _, SaltString}; use argon2::{Algorithm, Argon2, Params, Version}; use axum::http::HeaderMap; use chrono::{Duration, Utc}; -use rand_core::OsRng; use rusqlite::{OptionalExtension as _, params}; use sha2::{Digest as _, Sha256}; use uuid::Uuid; @@ -45,6 +45,11 @@ pub struct CurrentUser { pub is_suspended: bool, pub theme: Theme, pub nsfw_blur_enabled: bool, + /// Set by an administrator; the account is limited to the password-change + /// and logout flows until the password is changed. + pub must_change_password: bool, + /// Deadline for permanent account deletion, when a deletion is pending. + pub deletion_scheduled_at: Option, } #[derive(Debug, Clone)] @@ -62,6 +67,16 @@ pub enum LoginFailure { pub const USERNAME_TAKEN_MESSAGE: &str = "username is already taken"; +/// `SQLite` extended result code for a `UNIQUE` constraint failure. +const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; + +fn is_unique_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(code, _) if code.extended_code == SQLITE_CONSTRAINT_UNIQUE + ) +} + pub async fn register_user( pool: &SqlitePool, settings: &Settings, @@ -71,10 +86,11 @@ pub async fn register_user( ) -> anyhow::Result { let normalized = normalize_username(username, settings.accounts.max_username_len)?; validate_password(password, settings)?; - let hash = hash_password(password)?; + let hash = hash_password_async(password.to_owned()).await?; let username = username.trim().to_owned(); pool.call(move |conn| { - let existing = conn + let tx = conn.transaction()?; + let current_taken = tx .query_row( "SELECT 1 FROM users WHERE normalized_username = ?", [&normalized], @@ -82,14 +98,30 @@ pub async fn register_user( ) .optional()? .is_some(); - if existing { + let historical_taken = tx + .query_row( + "SELECT 1 FROM username_history WHERE normalized_username = ?", + [&normalized], + |_| Ok(()), + ) + .optional()? + .is_some(); + if current_taken || historical_taken { anyhow::bail!(USERNAME_TAKEN_MESSAGE); } - conn.execute( + let inserted = tx.execute( "INSERT INTO users (username, normalized_username, password_hash, display_name, is_admin) VALUES (?, ?, ?, ?, ?)", params![username, normalized, hash, username, i64::from(is_admin)], - )?; - Ok(conn.last_insert_rowid()) + ); + let user_id = match inserted { + Ok(_) => tx.last_insert_rowid(), + Err(error) if is_unique_violation(&error) => { + anyhow::bail!(USERNAME_TAKEN_MESSAGE); + } + Err(error) => return Err(error.into()), + }; + tx.commit()?; + Ok(user_id) }) .await } @@ -124,7 +156,7 @@ pub async fn login( if row.2 != 0 || row.3 != 0 { return Ok(Err(LoginFailure::UnavailableAccount)); } - if !verify_password(password, &row.1)? { + if !verify_password_async(password.to_owned(), row.1.clone()).await? { return Ok(Err(LoginFailure::InvalidPassword)); } create_session(pool, row.0).await.map(Ok) @@ -148,6 +180,23 @@ pub async fn create_session(pool: &SqlitePool, user_id: i64) -> anyhow::Result anyhow::Result { + let modifier = format!("-{grace_days} days"); + pool.call(move |conn| { + let changed = conn.execute( + r#" + DELETE FROM sessions + WHERE (revoked_at IS NOT NULL AND datetime(revoked_at) < datetime('now', ?1)) + OR datetime(expires_at) < datetime('now', ?1) + "#, + [modifier], + )?; + Ok(changed) + }) + .await +} + pub async fn revoke_session(pool: &SqlitePool, token: &str) -> anyhow::Result<()> { let token_hash = hash_token(token); pool.call(move |conn| { @@ -179,7 +228,21 @@ pub async fn verify_user_password( let Some(hash) = hash else { return Ok(false); }; - verify_password(password, &hash) + verify_password_async(password.to_owned(), hash).await +} + +/// Argon2id hashing is intentionally CPU and memory heavy; run it on the +/// blocking pool so it cannot stall the async runtime under load. +pub async fn hash_password_async(password: String) -> anyhow::Result { + tokio::task::spawn_blocking(move || 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)) + .await + .context("password verification task failed")? } pub async fn change_password( @@ -189,6 +252,7 @@ pub async fn change_password( current_password: &str, new_password: &str, confirm_new_password: &str, + current_session_token: Option<&str>, ) -> anyhow::Result<()> { if new_password != confirm_new_password { anyhow::bail!("new passwords do not match"); @@ -197,12 +261,32 @@ pub async fn change_password( if !verify_user_password(pool, user_id, current_password).await? { anyhow::bail!("current password is incorrect"); } - let hash = hash_password(new_password)?; + let hash = hash_password_async(new_password.to_owned()).await?; + let current_session_hash = current_session_token.map(hash_token); pool.call(move |conn| { - conn.execute( - "UPDATE users SET password_hash = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + let tx = conn.transaction()?; + let updated = tx.execute( + "UPDATE users SET password_hash = ?, must_change_password = 0, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", params![hash, user_id], )?; + if updated == 0 { + // The account was deleted after the password was verified. Rolling + // back keeps the flag and every session untouched instead of + // reporting a successful change that never happened. + anyhow::bail!("account not found"); + } + if let Some(current_session_hash) = current_session_hash { + tx.execute( + "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND token_hash != ? AND revoked_at IS NULL", + params![user_id, current_session_hash], + )?; + } else { + tx.execute( + "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", + [user_id], + )?; + } + tx.commit()?; Ok(()) }) .await @@ -219,10 +303,13 @@ pub async fn current_user( pool.call(move |conn| { conn.query_row( r#" - SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.theme, u.nsfw_blur_enabled + SELECT u.id, u.username, u.display_name, u.is_admin, u.is_suspended, u.theme, + u.nsfw_blur_enabled, u.must_change_password, u.deletion_scheduled_at FROM sessions s JOIN users u ON u.id = s.user_id - WHERE s.token_hash = ? AND s.revoked_at IS NULL AND s.expires_at > CURRENT_TIMESTAMP AND u.is_deleted = 0 + 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| { @@ -234,6 +321,8 @@ pub async fn current_user( is_suspended: row.get::<_, i64>(4)? != 0, theme: Theme::from(row.get::<_, String>(5)?.as_str()), nsfw_blur_enabled: row.get::<_, i64>(6)? != 0, + must_change_password: row.get::<_, i64>(7)? != 0, + deletion_scheduled_at: row.get(8)?, }) }, ) @@ -250,7 +339,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", + "SELECT csrf_token_hash, previous_csrf_token_hash FROM sessions WHERE token_hash = ? AND revoked_at IS NULL AND datetime(expires_at) > CURRENT_TIMESTAMP", [token_hash], |row| { let current = row.get::<_, String>(0)?; @@ -308,7 +397,10 @@ fn cookie_value(name: &str, value: &str, secure: bool, max_age: Option) -> } pub fn hash_password(password: &str) -> anyhow::Result { - let salt = SaltString::generate(&mut OsRng); + let mut salt_bytes = [0_u8; 16]; + getrandom::fill(&mut salt_bytes).context("generate password salt")?; + let salt = SaltString::encode_b64(&salt_bytes) + .map_err(|err| anyhow::anyhow!("encode password salt: {err}"))?; let params = Params::new(19_456, 2, 1, None) .map_err(|err| anyhow::anyhow!("invalid argon2 params: {err}"))?; Ok(Argon2::new(Algorithm::Argon2id, Version::V0x13, params) @@ -340,6 +432,78 @@ pub fn secure_token() -> String { mod tests { use super::*; + async fn fixture() -> (tempfile::TempDir, SqlitePool, Settings, i64) { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + (temp, pool, settings, user_id) + } + + fn session_headers(token: &str) -> HeaderMap { + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={token}") + .parse() + .expect("session cookie"), + ); + headers + } + + async fn set_must_change_password(pool: &SqlitePool, user_id: i64) { + pool.call(move |conn| { + conn.execute( + "UPDATE users SET must_change_password = 1 WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("set must_change_password"); + } + + async fn must_change_password(pool: &SqlitePool, user_id: i64) -> i64 { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT must_change_password FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("must_change_password") + } + + async fn active_session_count(pool: &SqlitePool, user_id: i64) -> i64 { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("active sessions") + } + + async fn password_hash(pool: &SqlitePool, user_id: i64) -> String { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT password_hash FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("password hash") + } + #[test] fn password_hash_verifies() { let hash = hash_password("correct horse battery").expect("hash"); @@ -360,11 +524,92 @@ mod tests { register_user(&pool, &settings, "Alice", "very secure password", false) .await .expect("first user"); - assert!( - register_user(&pool, &settings, "alice", "very secure password", false) + for candidate in ["alice", "ALICE", "aLiCe"] { + let error = register_user(&pool, &settings, candidate, "very secure password", false) .await - .is_err() - ); + .expect_err("case variant"); + assert_eq!(error.to_string(), USERNAME_TAKEN_MESSAGE); + } + let stored: String = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT username FROM users WHERE normalized_username = 'alice'", + (), + |row| row.get(0), + )?) + }) + .await + .expect("stored username"); + assert_eq!(stored, "Alice"); + } + + #[tokio::test] + async fn registration_rejects_historical_handle() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let alice = register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("alice"); + crate::identity::change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename alice"); + + for candidate in ["alice", "ALICE"] { + let error = register_user(&pool, &settings, candidate, "very secure password", false) + .await + .expect_err("historical handle"); + assert_eq!(error.to_string(), USERNAME_TAKEN_MESSAGE); + } + } + + #[tokio::test] + async fn password_change_clears_must_change_password() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + pool.call(move |conn| { + conn.execute( + "UPDATE users SET must_change_password = 1 WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("set flag"); + + change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + None, + ) + .await + .expect("password change"); + + let flag: i64 = pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT must_change_password FROM users WHERE id = ?", + [user_id], + |row| row.get(0), + )?) + }) + .await + .expect("flag"); + assert_eq!(flag, 0); } #[tokio::test] @@ -386,6 +631,7 @@ mod tests { "very secure password", "much better password", "much better password", + None, ) .await .expect("password change"); @@ -405,7 +651,7 @@ mod tests { } #[tokio::test] - async fn password_change_rejects_wrong_current_password() { + async fn password_change_revokes_other_sessions_but_keeps_current_session() { let temp = tempfile::tempdir().expect("temp dir"); let pool = crate::db::connect(&temp.path().join("test.sqlite3")) .await @@ -415,6 +661,57 @@ mod tests { let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) .await .expect("user"); + let current = create_session(&pool, user_id) + .await + .expect("current session"); + let other = create_session(&pool, user_id).await.expect("other session"); + + change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(¤t.token), + ) + .await + .expect("password change"); + + let mut current_headers = HeaderMap::new(); + current_headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={}", current.token) + .parse() + .expect("current cookie"), + ); + assert!( + current_user(&pool, ¤t_headers) + .await + .expect("current user") + .is_some() + ); + let mut other_headers = HeaderMap::new(); + other_headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={}", other.token) + .parse() + .expect("other cookie"), + ); + assert!( + current_user(&pool, &other_headers) + .await + .expect("other user") + .is_none() + ); + } + + #[tokio::test] + async fn password_change_rejects_wrong_current_password() { + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let current = create_session(&pool, user_id).await.expect("current"); + let other = create_session(&pool, user_id).await.expect("other"); let result = change_password( &pool, @@ -423,6 +720,7 @@ mod tests { "wrong password", "much better password", "much better password", + Some(¤t.token), ) .await; @@ -430,12 +728,31 @@ mod tests { result.expect_err("wrong current password").to_string(), "current password is incorrect" ); + assert_eq!(must_change_password(&pool, user_id).await, 1); + assert_eq!(active_session_count(&pool, user_id).await, 2); + assert!( + current_user(&pool, &session_headers(¤t.token)) + .await + .expect("current user") + .is_some() + ); + assert!( + current_user(&pool, &session_headers(&other.token)) + .await + .expect("other user") + .is_some() + ); assert!( login(&pool, "alice", "very secure password") .await .expect("old login") .is_ok() ); + assert_eq!( + active_session_count(&pool, user_id).await, + 3, + "login adds a session" + ); } #[tokio::test] @@ -457,6 +774,7 @@ mod tests { "very secure password", "much better password", "different password", + None, ) .await; @@ -465,4 +783,247 @@ mod tests { "new passwords do not match" ); } + + #[tokio::test] + async fn expired_rfc3339_session_is_rejected() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let user_id = register_user(&pool, &settings, "Alice", "very secure password", false) + .await + .expect("user"); + let session = create_session(&pool, user_id).await.expect("session"); + let token_hash = hash_token(&session.token); + pool.call(move |conn| { + conn.execute( + "UPDATE sessions SET expires_at = datetime('now', '-1 minute') || 'Z' WHERE token_hash = ?", + [token_hash], + )?; + Ok(()) + }) + .await + .expect("expire session"); + let mut headers = HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={}", session.token) + .parse() + .expect("cookie"), + ); + + assert!( + current_user(&pool, &headers) + .await + .expect("current user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, &session.token) + .await + .expect("csrf lookup") + .is_none() + ); + } + + #[tokio::test] + async fn password_change_keeps_current_session_revokes_others_and_clears_flag() { + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let first = create_session(&pool, user_id).await.expect("first"); + let second = create_session(&pool, user_id).await.expect("second"); + let third = create_session(&pool, user_id).await.expect("third"); + + change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(&first.token), + ) + .await + .expect("password change"); + + assert_eq!(must_change_password(&pool, user_id).await, 0); + let current = current_user(&pool, &session_headers(&first.token)) + .await + .expect("current user") + .expect("current session stays valid"); + assert!(!current.must_change_password); + for token in [&second.token, &third.token] { + assert!( + current_user(&pool, &session_headers(token)) + .await + .expect("revoked user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, token) + .await + .expect("csrf lookup") + .is_none() + ); + } + assert_eq!(active_session_count(&pool, user_id).await, 1); + assert!( + login(&pool, "alice", "much better password") + .await + .expect("new login") + .is_ok() + ); + assert!( + login(&pool, "alice", "very secure password") + .await + .expect("old login") + .is_err() + ); + } + + #[tokio::test] + async fn concurrent_password_change_and_read_leaves_no_stale_session() { + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let current = create_session(&pool, user_id).await.expect("current"); + let other = create_session(&pool, user_id).await.expect("other"); + + let change = change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(¤t.token), + ); + let other_headers = session_headers(&other.token); + let read = current_user(&pool, &other_headers); + let (change, read) = tokio::join!(change, read); + + change.expect("password change"); + if let Some(user) = read.expect("read") { + assert!( + user.must_change_password, + "a read that saw the other session must predate the password change" + ); + } + assert!( + current_user(&pool, &session_headers(&other.token)) + .await + .expect("final read") + .is_none(), + "no session may remain valid after the change" + ); + assert_eq!(active_session_count(&pool, user_id).await, 1); + } + + #[tokio::test] + async fn password_change_for_deleted_account_is_not_reported_as_success() { + use futures_util::FutureExt as _; + + let (_temp, pool, settings, user_id) = fixture().await; + set_must_change_password(&pool, user_id).await; + let current = create_session(&pool, user_id).await.expect("current"); + let _other = create_session(&pool, user_id).await.expect("other"); + let hash_before = password_hash(&pool, user_id).await; + + let change = change_password( + &pool, + &settings, + user_id, + "very secure password", + "much better password", + "much better password", + Some(¤t.token), + ); + tokio::pin!(change); + assert!( + change.as_mut().now_or_never().is_none(), + "first poll only queues password verification" + ); + // The account is deleted after the password was verified but before the + // UPDATE runs. + pool.call(move |conn| { + conn.execute( + "UPDATE users SET is_deleted = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + [user_id], + )?; + Ok(()) + }) + .await + .expect("delete mid-flight"); + + let error = change + .await + .expect_err("a deleted account must not report a successful change"); + assert_eq!(error.to_string(), "account not found"); + assert_eq!(password_hash(&pool, user_id).await, hash_before); + assert_eq!(must_change_password(&pool, user_id).await, 1); + assert_eq!( + active_session_count(&pool, user_id).await, + 2, + "a failed change must not revoke sessions" + ); + } + + #[tokio::test] + async fn revoked_session_cookie_is_rejected() { + let (_temp, pool, _settings, user_id) = fixture().await; + let revoked = create_session(&pool, user_id).await.expect("revoked"); + let active = create_session(&pool, user_id).await.expect("active"); + + revoke_session(&pool, &revoked.token).await.expect("revoke"); + + assert!( + current_user(&pool, &session_headers(&revoked.token)) + .await + .expect("current user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, &revoked.token) + .await + .expect("csrf lookup") + .is_none() + ); + assert!( + current_user(&pool, &session_headers(&active.token)) + .await + .expect("current user") + .is_some() + ); + } + + #[tokio::test] + async fn expired_inserted_session_cookie_is_rejected() { + let (_temp, pool, _settings, user_id) = fixture().await; + let token = secure_token(); + let token_hash = hash_token(&token); + let csrf_hash = hash_token(&secure_token()); + pool.call(move |conn| { + conn.execute( + "INSERT INTO sessions (user_id, token_hash, csrf_token_hash, expires_at) VALUES (?, ?, ?, datetime('now', '-1 hour'))", + params![user_id, token_hash, csrf_hash], + )?; + Ok(()) + }) + .await + .expect("insert expired session"); + + assert!( + current_user(&pool, &session_headers(&token)) + .await + .expect("current user") + .is_none() + ); + assert!( + csrf_hashes_for_cookie(&pool, &token) + .await + .expect("csrf lookup") + .is_none() + ); + } } diff --git a/src/backup.rs b/src/backup.rs index 851ca4e..7a1770e 100644 --- a/src/backup.rs +++ b/src/backup.rs @@ -6,7 +6,7 @@ use std::time::{Duration, SystemTime}; use anyhow::Context as _; use chrono::{SecondsFormat, Utc}; -use rusqlite::{Connection, OptionalExtension as _}; +use rusqlite::{Connection, OptionalExtension as _, params}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; use tar::{Archive, Builder, EntryType, Header}; @@ -109,6 +109,7 @@ pub fn restore_backup( let staged = extract_archive_to_stage(archive_path, staging.path(), include_tor_keys) .with_context(|| "backup archive failed validation")?; validate_staged_backup(staging.path(), &staged, include_tor_keys)?; + rewrite_restored_media_paths(&staging.path().join("db/rustpost.sqlite3"), paths)?; let pre_restore_backup = create_backup_inner(paths, include_tor_keys, BackupKind::PreRestore, false) .with_context(|| "failed to create pre-restore safety backup")?; @@ -383,10 +384,7 @@ fn create_backup_inner( let manifest = build_manifest(db_schema_version, include_tor_keys, &directories, &files); let manifest_toml = toml::to_string(&manifest)?; let archive_path = unique_archive_path(&paths.backups_dir, kind); - let file = OpenOptions::new() - .write(true) - .create_new(true) - .open(&archive_path) + let file = create_private_file(&archive_path) .with_context(|| format!("failed to create backup archive {}", archive_path.display()))?; let mut builder = Builder::new(file); append_bytes( @@ -406,6 +404,22 @@ fn create_backup_inner( Ok(archive_path) } +#[cfg(unix)] +fn create_private_file(path: &Path) -> io::Result { + use std::os::unix::fs::OpenOptionsExt as _; + + OpenOptions::new() + .write(true) + .create_new(true) + .mode(0o600) + .open(path) +} + +#[cfg(not(unix))] +fn create_private_file(path: &Path) -> io::Result { + OpenOptions::new().write(true).create_new(true).open(path) +} + fn append_runtime_dir( files: &mut Vec, directories: &mut BTreeSet, @@ -707,6 +721,17 @@ fn validate_staged_backup( } require_manifest_file(&manifest_entries, "db/rustpost.sqlite3")?; require_manifest_file(&manifest_entries, "settings.toml")?; + // Require the upload directories even when they are empty. Restore replaces + // the live upload roots, so an archive that silently omits them would wipe + // every media file referenced by the restored database. + for dir in [ + "uploads/originals", + "uploads/images", + "uploads/videos", + "uploads/thumbs", + ] { + require_manifest_directory(&manifest_entries, dir)?; + } for (path, manifest_entry) in manifest_entries { let extracted = staged @@ -789,6 +814,104 @@ fn require_manifest_file( Ok(()) } +fn require_manifest_directory( + manifest_entries: &BTreeMap, + path: &str, +) -> anyhow::Result<()> { + if manifest_entries + .get(path) + .is_none_or(|entry| entry.kind != ManifestEntryKind::Directory) + { + anyhow::bail!("backup manifest is missing required directory {path}"); + } + Ok(()) +} + +/// Rewrites absolute media paths in the staged database so they point at the +/// destination data directory. +/// +/// Media rows store absolute host paths. Restoring to a different data +/// directory would otherwise leave every path pointing at the source machine's +/// filesystem, which breaks media deletion, dedupe, and account deletion even +/// though the files themselves were restored correctly. +fn rewrite_restored_media_paths(database_path: &Path, paths: &RuntimePaths) -> anyhow::Result<()> { + let mut conn = Connection::open(database_path).with_context(|| { + format!( + "failed to open restored database {}", + database_path.display() + ) + })?; + conn.busy_timeout(Duration::from_secs(5))?; + let roots = [ + ("/uploads/originals/", &paths.uploads_originals), + ("/uploads/images/", &paths.uploads_images), + ("/uploads/videos/", &paths.uploads_videos), + ("/uploads/thumbs/", &paths.uploads_thumbs), + ]; + let tx = conn.transaction()?; + let rows = { + let mut stmt = tx.prepare( + "SELECT id, original_path, stored_path, thumbnail_path FROM media ORDER BY id", + )?; + stmt.query_map([], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, String>(2)?, + row.get::<_, Option>(3)?, + )) + })? + .collect::, _>>()? + }; + let mut rewritten = 0usize; + for (id, original_path, stored_path, thumbnail_path) in rows { + let new_original = original_path + .as_deref() + .map(|path| remap_media_path(path, &roots)); + let new_stored = remap_media_path(&stored_path, &roots); + let new_thumbnail = thumbnail_path + .as_deref() + .map(|path| remap_media_path(path, &roots)); + if new_original.as_deref() == original_path.as_deref() + && new_stored == stored_path + && new_thumbnail.as_deref() == thumbnail_path.as_deref() + { + continue; + } + tx.execute( + "UPDATE media SET original_path = ?, stored_path = ?, thumbnail_path = ? WHERE id = ?", + params![new_original, new_stored, new_thumbnail, id], + )?; + rewritten += 1; + } + tx.commit()?; + if rewritten > 0 { + tracing::info!( + rewritten, + "rewrote restored media paths for the destination data directory" + ); + } + Ok(()) +} + +fn remap_media_path(path: &str, roots: &[(&str, &PathBuf)]) -> String { + let normalized = if path.contains('\\') { + std::borrow::Cow::Owned(path.replace('\\', "/")) + } else { + std::borrow::Cow::Borrowed(path) + }; + for (marker, root) in roots { + if let Some(index) = normalized.find(marker) { + let relative = &normalized[index + marker.len()..]; + if relative.is_empty() { + return path.to_owned(); + } + return root.join(relative).to_string_lossy().to_string(); + } + } + path.to_owned() +} + fn swap_staged_runtime( paths: &RuntimePaths, staging_dir: &Path, @@ -1024,6 +1147,9 @@ fn validate_archive_path_text( } fn validate_allowed_archive_path(path: &str, include_tor_keys: bool) -> anyhow::Result<()> { + if !include_tor_keys && (path == "tor" || path.starts_with("tor/")) { + anyhow::bail!("archive contains Tor keys but restore did not opt in"); + } if path == MANIFEST_PATH || path == "settings.toml" || path == "db" @@ -1189,7 +1315,17 @@ fn hash_file(path: &Path) -> anyhow::Result<(u64, String)> { total = total.saturating_add(u64::try_from(read).unwrap_or(u64::MAX)); hasher.update(&buffer[..read]); } - Ok((total, format!("{:x}", hasher.finalize()))) + Ok((total, hex_lower(hasher.finalize().as_ref()))) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded } fn remove_path_if_exists(path: &Path) -> anyhow::Result<()> { @@ -1263,6 +1399,34 @@ mod tests { (temp, paths) } + #[test] + fn restored_media_paths_are_remapped_to_the_destination() { + let originals = PathBuf::from("/new/data/uploads/originals"); + let roots: [(&str, &PathBuf); 1] = [("/uploads/originals/", &originals)]; + let expected = originals.join("a.png").to_string_lossy().to_string(); + + assert_eq!( + remap_media_path("/old/data/uploads/originals/a.png", &roots), + expected + ); + assert_eq!( + remap_media_path(r"C:\old\data\uploads\originals\a.png", &roots), + expected + ); + assert_eq!( + remap_media_path("/new/data/uploads/originals/a.png", &roots), + expected + ); + assert_eq!( + remap_media_path("/unrelated/place/a.png", &roots), + "/unrelated/place/a.png" + ); + assert_eq!( + remap_media_path("/old/data/uploads/originals/", &roots), + "/old/data/uploads/originals/" + ); + } + #[test] fn backup_path_safety() { assert!(validate_archive_path(Path::new("uploads/images/a.webp"), false).is_ok()); @@ -1275,6 +1439,9 @@ mod tests { assert!(validate_archive_path(Path::new("uploads/∕/x"), false).is_err()); assert!(validate_archive_path(Path::new("tor/onion-service/key"), false).is_err()); assert!(validate_archive_path(Path::new("tor/onion-service/key"), true).is_ok()); + let tor_error = + validate_allowed_archive_path("tor/onion-service", false).expect_err("Tor opt-in"); + assert!(tor_error.to_string().contains("restore did not opt in")); } #[test] @@ -1304,6 +1471,23 @@ mod tests { assert!(names.iter().any(|name| name == "tor/onion-service/secret")); } + #[cfg(unix)] + #[test] + fn backup_archive_is_private() { + use std::os::unix::fs::PermissionsExt as _; + + let (_temp, paths) = test_paths(); + + let archive = create_backup(&paths, true).expect("backup"); + let mode = fs::metadata(archive) + .expect("archive metadata") + .permissions() + .mode() + & 0o777; + + assert_eq!(mode, 0o600); + } + #[test] fn backup_normalizes_valid_pre_release_schema_without_touching_live_database() { let (_temp, paths) = test_paths(); @@ -1376,15 +1560,158 @@ mod tests { test_db(&target.database_path, CURRENT_SCHEMA_VERSION); test_settings(&target.settings_path); fs::write(target.uploads_images.join("old.webp"), b"old").expect("old"); + let live_db = fs::read(&target.database_path).expect("live database"); + let live_settings = fs::read_to_string(&target.settings_path).expect("live settings"); let corrupted = corrupt_first_file_hash(&archive, target_temp.path()); let error = restore_backup(&target, &corrupted, false).expect_err("restore must fail"); - assert!(error.to_string().contains("validation")); + assert!(format!("{error:#}").contains("validation")); + assert_eq!( + fs::read(&target.database_path).expect("live database"), + live_db + ); + assert_eq!( + fs::read_to_string(&target.settings_path).expect("live settings"), + live_settings + ); assert!(target.uploads_images.join("old.webp").is_file()); assert!(!target.uploads_images.join("restored.webp").exists()); } + #[test] + fn backup_restore_round_trip_preserves_v4_account_lifecycle_state() { + let source_temp = tempfile::tempdir().expect("source"); + let source = RuntimePaths::from_data_dir(source_temp.path().join("source")); + source.ensure().expect("source ensure"); + test_db(&source.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&source.settings_path); + { + let conn = Connection::open(&source.database_path).expect("source database"); + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name, follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice', 1, 1, '2026-01-01T00:00:00Z', '2026-02-01T00:00:00Z'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + INSERT INTO follow_requests (requester_id, target_id, created_at) + VALUES (2, 1, '2026-01-03T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OldAlice', 'oldalice', '2026-01-04T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OlderAlice', 'olderalice', '2026-01-05T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('announcement', 'Maintenance on Sunday', '2026-01-06T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('announcement_enabled', '1', '2026-01-06T00:00:01Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('maintenance_mode', '1', '2026-01-06T00:00:02Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('maintenance_message', 'Back at 06:00 UTC', '2026-01-06T00:00:03Z'); + INSERT INTO account_imports ( + user_id, archive_id, format_version, imported_at, + posts_imported, media_imported, follows_imported, follows_pending, follows_skipped + ) + VALUES (1, 'archive-abc', 2, '2026-01-07T00:00:00Z', 11, 4, 7, 2, 1); + "#, + ) + .expect("seed v4 rows"); + } + let source_snapshot = account_lifecycle_snapshot(&source.database_path); + + let archive = create_backup(&source, false).expect("backup"); + let manifest = read_manifest_from_archive(&archive).expect("manifest"); + assert_eq!(manifest.db_schema_version, Some(CURRENT_SCHEMA_VERSION)); + + let target_temp = tempfile::tempdir().expect("target"); + let target = RuntimePaths::from_data_dir(target_temp.path().join("target")); + target.ensure().expect("target ensure"); + test_db(&target.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&target.settings_path); + + let report = restore_backup(&target, &archive, false).expect("restore"); + + assert!(!report.tor_keys_restored); + assert_eq!( + account_lifecycle_snapshot(&target.database_path), + source_snapshot + ); + let conn = Connection::open(&target.database_path).expect("restored database"); + assert_eq!( + db::schema_version_from_connection(&conn).expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + } + + #[test] + fn restore_rejects_newer_database_schema_version_without_touching_live_data() { + assert_crafted_backup_rejected( + |staging, staged| { + bump_staged_schema_version(staging, CURRENT_SCHEMA_VERSION + 1); + staged.manifest.db_schema_version = Some(CURRENT_SCHEMA_VERSION + 1); + }, + "migration metadata is not compatible", + ); + } + + #[test] + fn restore_rejects_manifest_schema_version_mismatch_without_touching_live_data() { + assert_crafted_backup_rejected( + |_staging, staged| { + staged.manifest.db_schema_version = Some(CURRENT_SCHEMA_VERSION - 1); + }, + "manifest schema version does not match restored database", + ); + } + + #[test] + fn restore_rejects_newer_manifest_format_without_touching_live_data() { + assert_crafted_backup_rejected( + |_staging, staged| { + staged.manifest.format_version = FORMAT_VERSION + 1; + }, + "unsupported backup manifest format version", + ); + } + + #[test] + fn restore_rejects_malformed_database_schema_without_touching_live_data() { + assert_crafted_backup_rejected( + |staging, _staged| { + let conn = + Connection::open(staging.join("db/rustpost.sqlite3")).expect("staged database"); + conn.execute("DROP INDEX idx_posts_created", []) + .expect("drop staged index"); + }, + "missing index idx_posts_created", + ); + } + + #[test] + fn restore_rejects_malformed_manifest_without_touching_live_data() { + let target_temp = tempfile::tempdir().expect("target"); + let (paths, db_bytes, settings) = live_target(target_temp.path(), "target"); + let staging = tempfile::tempdir().expect("staging"); + let archive = staging.path().join("malformed-manifest.tar"); + let file = File::create(&archive).expect("archive"); + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + b"format_version = \"one\"", + 0o600, + EntryType::Regular, + ) + .expect("manifest"); + builder.finish().expect("finish"); + + let error = restore_backup(&paths, &archive, false).expect_err("malformed manifest"); + + let message = format!("{error:#}"); + assert!(message.contains("malformed"), "unexpected error: {message}"); + assert_live_target_untouched(&paths, &db_bytes, &settings); + } + #[test] fn restore_rejects_malicious_archive_entries() { let (_temp, paths) = test_paths(); @@ -1412,10 +1739,11 @@ mod tests { let archive = paths.tmp_dir.join("duplicate.tar"); let file = File::create(&archive).expect("archive"); let mut builder = Builder::new(file); + let manifest_toml = toml::to_string(&minimal_manifest()).expect("manifest toml"); append_bytes( &mut builder, MANIFEST_PATH, - b"not toml", + manifest_toml.as_bytes(), 0o600, EntryType::Regular, ) @@ -1440,7 +1768,10 @@ mod tests { let error = restore_backup(&paths, &archive, false).expect_err("duplicate"); - assert!(error.to_string().contains("validation")); + assert!( + format!("{error:#}").contains("duplicate entry settings.toml"), + "unexpected error: {error:#}" + ); } #[test] @@ -1486,6 +1817,32 @@ mod tests { assert!(error.to_string().contains("already running")); } + #[test] + fn restore_rejects_archives_that_omit_upload_directories() { + let source_temp = tempfile::tempdir().expect("source"); + let source = RuntimePaths::from_data_dir(source_temp.path().join("source")); + source.ensure().expect("source ensure"); + test_db(&source.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&source.settings_path); + let archive = create_backup(&source, false).expect("backup"); + + let staging = tempfile::tempdir().expect("staging"); + let mut staged = + extract_archive_to_stage(&archive, staging.path(), false).expect("extract"); + assert!(staged.entries.remove("uploads/images").is_some()); + staged + .manifest + .components + .retain(|entry| entry.archive_path != "uploads/images"); + + let error = validate_staged_backup(staging.path(), &staged, false) + .expect_err("missing upload directory must be rejected"); + assert!( + error.to_string().contains("uploads/images"), + "unexpected error: {error}" + ); + } + #[test] fn automatic_retention_only_deletes_automatic_backups() { let (_temp, paths) = test_paths(); @@ -1533,6 +1890,223 @@ mod tests { ); } + type SnapshotUserRow = (i64, String, i64, i64, Option, Option); + type SnapshotAccountImportRow = (i64, String, i64, String, i64, i64, i64, i64, i64); + + #[derive(Debug, PartialEq, Eq)] + struct V4AccountLifecycleSnapshot { + users: Vec, + follow_requests: Vec<(i64, i64, String)>, + username_history: Vec<(i64, String, String, String)>, + instance_settings: Vec<(String, String, String)>, + account_imports: Vec, + } + + fn account_lifecycle_snapshot(path: &Path) -> V4AccountLifecycleSnapshot { + let conn = Connection::open(path).expect("snapshot database"); + let users = { + let mut stmt = conn + .prepare( + "SELECT id, username, follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users ORDER BY id", + ) + .expect("users statement"); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }) + .expect("users query") + .collect::>>() + .expect("users rows") + }; + let follow_requests = { + let mut stmt = conn + .prepare( + "SELECT requester_id, target_id, created_at FROM follow_requests ORDER BY requester_id, target_id", + ) + .expect("follow requests statement"); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("follow requests query") + .collect::>>() + .expect("follow request rows") + }; + let username_history = { + let mut stmt = conn + .prepare( + "SELECT user_id, username, normalized_username, changed_at FROM username_history ORDER BY user_id, normalized_username", + ) + .expect("username history statement"); + stmt.query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + }) + .expect("username history query") + .collect::>>() + .expect("username history rows") + }; + let instance_settings = { + let mut stmt = conn + .prepare("SELECT key, value, updated_at FROM instance_settings ORDER BY key") + .expect("instance settings statement"); + stmt.query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?))) + .expect("instance settings query") + .collect::>>() + .expect("instance settings rows") + }; + let account_imports = { + let mut stmt = conn + .prepare( + "SELECT user_id, archive_id, format_version, imported_at, posts_imported, media_imported, follows_imported, follows_pending, follows_skipped FROM account_imports ORDER BY id", + ) + .expect("account imports statement"); + stmt.query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) + }) + .expect("account imports query") + .collect::>>() + .expect("account import rows") + }; + V4AccountLifecycleSnapshot { + users, + follow_requests, + username_history, + instance_settings, + account_imports, + } + } + + fn minimal_manifest() -> BackupManifest { + BackupManifest { + format_version: FORMAT_VERSION, + rustpost_version: "test".to_owned(), + db_schema_version: None, + created_at: "2026-01-01T00:00:00Z".to_owned(), + tor_keys_included: false, + components: Vec::new(), + } + } + + fn live_target(root: &Path, tag: &str) -> (RuntimePaths, Vec, String) { + let paths = RuntimePaths::from_data_dir(root.join(tag)); + paths.ensure().expect("ensure target"); + test_db(&paths.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&paths.settings_path); + let db_bytes = fs::read(&paths.database_path).expect("live database bytes"); + let settings = fs::read_to_string(&paths.settings_path).expect("live settings"); + (paths, db_bytes, settings) + } + + fn assert_live_target_untouched(paths: &RuntimePaths, db_bytes: &[u8], settings: &str) { + assert_eq!( + fs::read(&paths.database_path).expect("live database"), + db_bytes + ); + assert_eq!( + fs::read_to_string(&paths.settings_path).expect("live settings"), + settings + ); + assert!( + list_backups(paths).expect("backup list").is_empty(), + "a rejected restore must not create backups" + ); + } + + fn crafted_backup( + mutate: impl FnOnce(&Path, &mut StagedBackup), + ) -> (tempfile::TempDir, PathBuf) { + let source_temp = tempfile::tempdir().expect("source"); + let source = RuntimePaths::from_data_dir(source_temp.path().join("source")); + source.ensure().expect("source ensure"); + test_db(&source.database_path, CURRENT_SCHEMA_VERSION); + test_settings(&source.settings_path); + let archive = create_backup(&source, false).expect("backup"); + + let staging = tempfile::tempdir().expect("staging"); + let mut staged = + extract_archive_to_stage(&archive, staging.path(), false).expect("extract archive"); + mutate(staging.path(), &mut staged); + let crafted = staging.path().join("crafted.tar"); + repack_archive(staging.path(), &mut staged, &crafted).expect("repack archive"); + (staging, crafted) + } + + fn repack_archive( + staging_dir: &Path, + staged: &mut StagedBackup, + destination: &Path, + ) -> anyhow::Result<()> { + for component in &mut staged.manifest.components { + if component.kind == ManifestEntryKind::File { + let (size, hash) = hash_file(&staging_dir.join(&component.archive_path))?; + component.size = size; + component.sha256 = Some(hash); + } + } + let manifest_toml = toml::to_string(&staged.manifest)?; + let file = File::create(destination)?; + let mut builder = Builder::new(file); + append_bytes( + &mut builder, + MANIFEST_PATH, + manifest_toml.as_bytes(), + 0o600, + EntryType::Regular, + )?; + for (path, entry) in &staged.entries { + match entry.kind { + ManifestEntryKind::Directory => { + append_bytes(&mut builder, path, &[], 0o700, EntryType::Directory)?; + } + ManifestEntryKind::File => { + let bytes = fs::read(staging_dir.join(path))?; + append_bytes(&mut builder, path, &bytes, 0o600, EntryType::Regular)?; + } + } + } + builder.finish()?; + Ok(()) + } + + fn bump_staged_schema_version(staging_dir: &Path, version: i64) { + let conn = Connection::open(staging_dir.join("db/rustpost.sqlite3")).expect("staged db"); + conn.execute("DELETE FROM schema_migrations", []) + .expect("clear staged version"); + conn.execute( + "INSERT INTO schema_migrations (version) VALUES (?)", + [version], + ) + .expect("staged version"); + } + + fn assert_crafted_backup_rejected( + mutate: impl FnOnce(&Path, &mut StagedBackup), + expected: &str, + ) { + let (_staging, archive) = crafted_backup(mutate); + let target_temp = tempfile::tempdir().expect("target"); + let (paths, db_bytes, settings) = live_target(target_temp.path(), "target"); + + let error = restore_backup(&paths, &archive, false).expect_err("crafted backup"); + + let message = format!("{error:#}"); + assert!(message.contains(expected), "unexpected error: {message}"); + assert_live_target_untouched(&paths, &db_bytes, &settings); + } + fn archive_names(path: &Path) -> Vec { let file = File::open(path).expect("archive"); Archive::new(file) diff --git a/src/cli.rs b/src/cli.rs index 7f2f790..e0d6e29 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -3,6 +3,7 @@ use std::net::SocketAddr; use std::path::PathBuf; #[cfg(unix)] use std::process::Command as ProcessCommand; +use std::time::Duration; use anyhow::Context as _; use clap::{Parser, Subcommand}; @@ -13,6 +14,8 @@ use crate::{admin, backup, config, db, demo_seed, logging, runtime, server, term #[derive(Debug, Parser)] #[command( + name = "rustpost-cli", + version, about = "Single-binary self-hosted microblog", after_help = "Common first run:\n rustpost-cli init\n rustpost-cli create-admin-interactive\n rustpost-cli serve" )] @@ -81,10 +84,6 @@ enum Command { include_tor_keys: bool, }, - /// Print only the configured onion address when one is available. - #[command(about = "Print the onion address when one is available")] - PrintOnionAddress, - /// Start the `RustPost` web server. #[command(about = "Start the RustPost web server")] Serve, @@ -103,6 +102,9 @@ pub async fn run() -> anyhow::Result<()> { paths = paths .with_tor_data_dir(&settings.tor.data_dir) .with_backup_dir(&settings.backup.backup_dir); + // Keep backup and restore pointed at the settings file the operator + // actually loaded, including an explicit --config path. + paths.settings_path.clone_from(&settings_path); paths.ensure()?; info!(data_dir = %paths.data_dir.display(), settings = %settings_path.display(), "runtime paths ready"); @@ -170,14 +172,6 @@ async fn run_command( archive, include_tor_keys, } => restore_command(&paths, &archive, include_tor_keys), - Command::PrintOnionAddress => { - let status = tor::validate_startup(&settings.tor); - stdout_line(format_args!( - "{}", - status.onion_address().unwrap_or_default() - ))?; - Ok(()) - } Command::Serve => serve(paths, settings_path, settings).await, } } @@ -347,10 +341,17 @@ async fn serve( }, ) .await?; - let state = server::AppState::new(pool, settings.clone(), paths.clone(), ffmpeg, tor_status); + let state = server::AppState::new( + pool.clone(), + settings.clone(), + paths.clone(), + ffmpeg, + tor_status, + ); let app = server::router(state); let shutdown_rx = shutdown_receiver(); backup::spawn_automatic_scheduler(paths.clone(), settings_path, shutdown_rx.clone()); + server::spawn_maintenance_scheduler(pool, paths.clone(), shutdown_rx.clone()); if settings.tor.tor_only { return serve_tor_only(onion_listener, app, shutdown_rx).await; @@ -522,6 +523,7 @@ fn spawn_onion_forwarding( if !started.running() { return; } + spawn_tor_status_sync(tor_status.clone(), started, shutdown_rx.clone()); let addr = match listener.local_addr() { Ok(addr) => addr, Err(error) => { @@ -542,6 +544,27 @@ fn spawn_onion_forwarding( }); } +fn spawn_tor_status_sync( + visible: tor::TorStatus, + running: tor::TorStatus, + mut shutdown_rx: watch::Receiver, +) { + tokio::spawn(async move { + loop { + tokio::select! { + () = tokio::time::sleep(Duration::from_secs(1)) => { + visible.replace_snapshot_with(&running); + } + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + } + } + }); +} + struct StartupPrintContext<'a> { paths: &'a runtime::RuntimePaths, settings_path: &'a std::path::Path, diff --git a/src/config.rs b/src/config.rs index 6a64428..0823a68 100644 --- a/src/config.rs +++ b/src/config.rs @@ -5,6 +5,29 @@ use serde::{Deserialize, Serialize}; pub const DEFAULT_POST_EDIT_WINDOW_SECONDS: u64 = 15; pub const MAX_POST_EDIT_WINDOW_SECONDS: u64 = 300; +pub const DEFAULT_DELETION_GRACE_PERIOD_DAYS: u64 = 30; +pub const MAX_DELETION_GRACE_PERIOD_DAYS: u64 = 3_650; + +/// Default ceiling for the compressed size of one account archive. +/// +/// Applies both to upload (`/settings/import`) and to export output. This is +/// the *compressed* limit; see [`DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES`] for the +/// independent decompressed-content ceiling. +pub const DEFAULT_MAX_ARCHIVE_UPLOAD_BYTES: u64 = 300 * 1024 * 1024; +/// Default ceiling for the total decompressed media bytes extracted from one +/// account archive. Independent of the compressed upload limit so a small +/// archive can never expand into unbounded disk use. +pub const DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES: u64 = 1024 * 1024 * 1024; +/// Default ceiling for the number of tar entries in one account archive. +pub const DEFAULT_MAX_ARCHIVE_ENTRIES: usize = 10_000; +/// Upper bound accepted for `accounts.max_archive_upload_bytes`. +pub const MAX_ARCHIVE_UPLOAD_LIMIT_BYTES: u64 = 2 * 1024 * 1024 * 1024; +/// Upper bound accepted for `accounts.max_archive_expanded_bytes`. +pub const MAX_ARCHIVE_EXPANDED_LIMIT_BYTES: u64 = 16 * 1024 * 1024 * 1024; +/// Upper bound accepted for `accounts.max_archive_entries`. +pub const MAX_ARCHIVE_ENTRY_LIMIT: usize = 100_000; +/// Smallest limit operators may configure for either archive byte ceiling. +pub const MIN_ARCHIVE_LIMIT_BYTES: u64 = 64 * 1024; #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { @@ -55,6 +78,21 @@ pub struct AccountSettings { pub max_bio_len: usize, pub allow_profile_banners: bool, pub allow_profile_pictures: bool, + /// Days an account remains recoverable after requesting deletion. A value + /// of zero finalizes deletion immediately. + #[serde(default = "default_deletion_grace_period_days")] + pub deletion_grace_period_days: u64, + /// Compressed-size ceiling for account archives, in bytes. Applies to the + /// import upload and to export output. Independent of the expanded limit. + #[serde(default = "default_max_archive_upload_bytes")] + pub max_archive_upload_bytes: u64, + /// Maximum total decompressed media bytes accepted from one account + /// archive, in bytes. Checked incrementally while extracting. + #[serde(default = "default_max_archive_expanded_bytes")] + pub max_archive_expanded_bytes: u64, + /// Maximum number of tar entries accepted in one account archive. + #[serde(default = "default_max_archive_entries")] + pub max_archive_entries: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -172,6 +210,10 @@ impl Default for Settings { max_bio_len: 240, allow_profile_banners: true, allow_profile_pictures: true, + deletion_grace_period_days: DEFAULT_DELETION_GRACE_PERIOD_DAYS, + max_archive_upload_bytes: DEFAULT_MAX_ARCHIVE_UPLOAD_BYTES, + max_archive_expanded_bytes: DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES, + max_archive_entries: DEFAULT_MAX_ARCHIVE_ENTRIES, }, posts: PostSettings { max_text_chars: 280, @@ -267,9 +309,40 @@ impl Settings { if self.tor.tor_only && !self.tor.enabled { anyhow::bail!("tor.tor_only requires tor.enabled"); } + if self.tor.include_tor_keys_in_backups_by_default { + anyhow::bail!( + "tor.include_tor_keys_in_backups_by_default must remain false; use an explicit include-Tor-keys backup option" + ); + } validate_onion_service_name(&self.tor.onion_service_name)?; validate_display_onion_address(&self.tor.display_onion_address)?; validate_post_edit_window(self.posts.post_edit_window_seconds)?; + if self.accounts.deletion_grace_period_days > MAX_DELETION_GRACE_PERIOD_DAYS { + anyhow::bail!( + "accounts.deletion_grace_period_days must be {MAX_DELETION_GRACE_PERIOD_DAYS} days or less" + ); + } + if self.accounts.max_archive_upload_bytes < MIN_ARCHIVE_LIMIT_BYTES + || self.accounts.max_archive_upload_bytes > MAX_ARCHIVE_UPLOAD_LIMIT_BYTES + { + anyhow::bail!( + "accounts.max_archive_upload_bytes must be between {MIN_ARCHIVE_LIMIT_BYTES} and {MAX_ARCHIVE_UPLOAD_LIMIT_BYTES} bytes" + ); + } + if self.accounts.max_archive_expanded_bytes < MIN_ARCHIVE_LIMIT_BYTES + || self.accounts.max_archive_expanded_bytes > MAX_ARCHIVE_EXPANDED_LIMIT_BYTES + { + anyhow::bail!( + "accounts.max_archive_expanded_bytes must be between {MIN_ARCHIVE_LIMIT_BYTES} and {MAX_ARCHIVE_EXPANDED_LIMIT_BYTES} bytes" + ); + } + if self.accounts.max_archive_entries == 0 + || self.accounts.max_archive_entries > MAX_ARCHIVE_ENTRY_LIMIT + { + anyhow::bail!( + "accounts.max_archive_entries must be between 1 and {MAX_ARCHIVE_ENTRY_LIMIT}" + ); + } Ok(()) } } @@ -278,6 +351,22 @@ const fn default_post_edit_window_seconds() -> u64 { DEFAULT_POST_EDIT_WINDOW_SECONDS } +const fn default_deletion_grace_period_days() -> u64 { + DEFAULT_DELETION_GRACE_PERIOD_DAYS +} + +const fn default_max_archive_upload_bytes() -> u64 { + DEFAULT_MAX_ARCHIVE_UPLOAD_BYTES +} + +const fn default_max_archive_expanded_bytes() -> u64 { + DEFAULT_MAX_ARCHIVE_EXPANDED_BYTES +} + +const fn default_max_archive_entries() -> usize { + DEFAULT_MAX_ARCHIVE_ENTRIES +} + fn validate_post_edit_window(seconds: u64) -> anyhow::Result<()> { if seconds > MAX_POST_EDIT_WINDOW_SECONDS { anyhow::bail!( @@ -354,13 +443,13 @@ anonymous_mode_enabled = {anonymous_mode_enabled} # Minimum account password length. Recommended default is 10. min_password_length = {min_password_length} -# Maximum username length in bytes. Usernames are also format-validated. +# Maximum username length in characters. Usernames are also format-validated. max_username_len = {max_username_len} -# Maximum display-name length in bytes. +# Maximum display-name length in characters. max_display_name_len = {max_display_name_len} -# Maximum profile bio length in bytes. +# Maximum profile bio length in characters. max_bio_len = {max_bio_len} # Allow users to upload profile banner images. @@ -369,6 +458,22 @@ allow_profile_banners = {allow_profile_banners} # Allow users to upload profile picture images. allow_profile_pictures = {allow_profile_pictures} +# Days an account stays recoverable after deletion is requested. Set to 0 to +# delete immediately after password confirmation. +deletion_grace_period_days = {deletion_grace_period_days} + +# SECURITY: Maximum compressed size in bytes of one account archive. Applies to +# the /settings/import upload and to exported archives. Independent of the +# expanded limit below, so this value never controls decompressed content. +max_archive_upload_bytes = {max_archive_upload_bytes} + +# SECURITY: Maximum total decompressed media bytes accepted when importing one +# account archive. Enforced incrementally during extraction. +max_archive_expanded_bytes = {max_archive_expanded_bytes} + +# SECURITY: Maximum number of entries accepted in one account archive. +max_archive_entries = {max_archive_entries} + # Posts and interactions @@ -467,8 +572,8 @@ data_dir = {tor_data_dir} # Local name for the onion service state directory. onion_service_name = {onion_service_name} -# Optional stable onion address to show in the site header before Arti has -# reported the active service address. Leave blank to use the runtime address. +# Deprecated compatibility setting. Must remain blank; the public UI only +# shows the active address reported by the running Arti onion service. display_onion_address = {display_onion_address} # Seconds to wait for Tor bootstrap during Tor-only startup. @@ -477,8 +582,8 @@ bootstrap_timeout_secs = {bootstrap_timeout_secs} # Maximum concurrent onion streams accepted by the service. max_concurrent_streams = {max_concurrent_streams} -# SECURITY: Include onion service keys when backups are created by default. -# Keep false unless backups are encrypted and access-controlled. +# Deprecated compatibility setting. Must remain false; Tor keys are included +# only with an explicit backup option. include_tor_keys_in_backups_by_default = {include_tor_keys_in_backups_by_default} @@ -553,6 +658,10 @@ automatic_include_tor_keys = {backup_automatic_include_tor_keys} max_bio_len = settings.accounts.max_bio_len, allow_profile_banners = settings.accounts.allow_profile_banners, allow_profile_pictures = settings.accounts.allow_profile_pictures, + deletion_grace_period_days = settings.accounts.deletion_grace_period_days, + max_archive_upload_bytes = settings.accounts.max_archive_upload_bytes, + max_archive_expanded_bytes = settings.accounts.max_archive_expanded_bytes, + max_archive_entries = settings.accounts.max_archive_entries, 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, @@ -656,22 +765,9 @@ fn validate_display_onion_address(value: &str) -> anyhow::Result<()> { if value.is_empty() { return Ok(()); } - if value.trim() != value { - anyhow::bail!("tor.display_onion_address must not contain surrounding whitespace"); - } - let Some(service_id) = value.strip_suffix(".onion") else { - anyhow::bail!("tor.display_onion_address must end with .onion"); - }; - if service_id.len() != 56 { - anyhow::bail!("tor.display_onion_address must be a v3 onion address"); - } - if !service_id - .bytes() - .all(|byte| byte.is_ascii_lowercase() || (b'2'..=b'7').contains(&byte)) - { - anyhow::bail!("tor.display_onion_address must contain only lowercase base32 characters"); - } - Ok(()) + anyhow::bail!( + "tor.display_onion_address must remain blank; RustPost only displays the active Arti onion address" + ) } const fn default_true() -> bool { @@ -834,18 +930,24 @@ mod tests { } #[test] - fn validates_display_onion_address() { + fn rejects_configured_display_onion_address() { let mut settings = Settings::default(); settings.tor.display_onion_address = "abcdefghijklmnopqrstuvwxyz234567abcdefghijklmnopqrstuvwx.onion".to_owned(); - settings.validate().expect("valid display onion address"); + let error = settings.validate().expect_err("display address must fail"); + assert!(error.to_string().contains("only displays the active Arti")); + } - settings.tor.display_onion_address = "examplehiddenservice.onion".to_owned(); - assert!(settings.validate().is_err()); + #[test] + fn rejects_implicit_tor_key_backups() { + let mut settings = Settings::default(); + settings.tor.include_tor_keys_in_backups_by_default = true; - settings.tor.display_onion_address = - "ABCDEFGHIJKLMNOPQRSTUVWXYZ234567ABCDEFGHIJKLMNOPQRSTUVWX.onion".to_owned(); - assert!(settings.validate().is_err()); + let error = settings + .validate() + .expect_err("implicit Tor key backup must fail"); + + assert!(error.to_string().contains("explicit include-Tor-keys")); } #[test] diff --git a/src/db.rs b/src/db.rs index d256062..2207662 100644 --- a/src/db.rs +++ b/src/db.rs @@ -6,9 +6,14 @@ use std::time::Duration; use anyhow::Context as _; use rusqlite::{Connection, OpenFlags, OptionalExtension as _}; -pub const CURRENT_SCHEMA_VERSION: i64 = 2; - -const PREVIOUS_RELEASE_SCHEMA_VERSION: i64 = 1; +pub const CURRENT_SCHEMA_VERSION: i64 = 4; + +/// Pre-1.0 schema version 1 (without the `YouTube` embed tables). +const RELEASE_V1_SCHEMA_VERSION: i64 = 1; +/// Pre-1.0 schema version 2 (with the `YouTube` embed tables). +const RELEASE_V2_SCHEMA_VERSION: i64 = 2; +/// Pre-1.0 schema version 3 (with the release indexes). +const RELEASE_V3_SCHEMA_VERSION: i64 = 3; const OLD_ALPHA_SCHEMA_VERSION: i64 = 13; const INCOMPATIBLE_DATABASE_HINT: &str = "back up or export the instance, recreate a fresh RustPost data directory, and restore from a known-good backup instead of attempting a blind migration"; @@ -70,9 +75,35 @@ fn open_connection(path: &Path) -> anyhow::Result { conn.busy_timeout(Duration::from_secs(5))?; conn.pragma_update(None, "foreign_keys", "ON")?; conn.pragma_update(None, "journal_mode", "WAL")?; + register_sql_helpers(&conn)?; Ok(conn) } +/// Registers the Rust-backed SQL helpers used by application queries. +/// +/// `rustpost_muted_word_match(text, term)` performs a Unicode-aware, +/// case-insensitive substring test. `SQLite`'s built-in `lower()` only folds +/// ASCII characters, which made muted-word matching miss uppercase accented +/// text. Registering this on the connection keeps the check inside SQL so +/// muted rows are filtered before `LIMIT` is applied. +fn register_sql_helpers(conn: &Connection) -> anyhow::Result<()> { + conn.create_scalar_function( + "rustpost_muted_word_match", + 2, + rusqlite::functions::FunctionFlags::SQLITE_UTF8 + | rusqlite::functions::FunctionFlags::SQLITE_DETERMINISTIC, + |context| { + let text = context.get::>(0)?.unwrap_or_default(); + let term = context.get::>(1)?.unwrap_or_default(); + if term.is_empty() { + return Ok(false); + } + Ok(text.to_lowercase().contains(&term.to_lowercase())) + }, + )?; + Ok(()) +} + pub async fn migrate(pool: &Db) -> anyhow::Result<()> { pool.call(|conn| { let tx = conn.transaction()?; @@ -176,6 +207,8 @@ impl SchemaReport { enum MigrationState { Empty, Baseline, + ReleaseVersion3, + ReleaseVersion2, ReleaseVersion1, AlphaLatest, Incompatible(String), @@ -217,6 +250,8 @@ fn ensure_release_baseline(conn: &Connection) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Empty => initialize_release_baseline(conn)?, MigrationState::Baseline => {} + MigrationState::ReleaseVersion3 => migrate_release_v3_to_current(conn)?, + MigrationState::ReleaseVersion2 => migrate_release_v2_to_current(conn)?, MigrationState::ReleaseVersion1 => migrate_release_v1_to_current(conn)?, MigrationState::AlphaLatest => adopt_latest_alpha_schema(conn)?, MigrationState::Incompatible(reason) => { @@ -233,10 +268,28 @@ fn validate_or_normalize_restorable_schema( ) -> anyhow::Result<()> { match migration_state(conn)? { MigrationState::Baseline => validate_schema(conn), + MigrationState::ReleaseVersion3 => { + if !normalize { + anyhow::bail!( + "database schema version {RELEASE_V3_SCHEMA_VERSION} requires migration before use" + ); + } + migrate_release_v3_to_current(conn)?; + validate_schema(conn) + } + MigrationState::ReleaseVersion2 => { + if !normalize { + anyhow::bail!( + "database schema version {RELEASE_V2_SCHEMA_VERSION} requires migration before use" + ); + } + migrate_release_v2_to_current(conn)?; + validate_schema(conn) + } MigrationState::ReleaseVersion1 => { if !normalize { anyhow::bail!( - "database schema version {PREVIOUS_RELEASE_SCHEMA_VERSION} requires migration before use" + "database schema version {RELEASE_V1_SCHEMA_VERSION} requires migration before use" ); } migrate_release_v1_to_current(conn)?; @@ -252,7 +305,7 @@ fn validate_or_normalize_restorable_schema( ); } if normalize { - reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION)?; migrate_release_v1_to_current(conn)?; validate_schema(conn)?; } @@ -302,7 +355,13 @@ fn migration_state(conn: &Connection) -> anyhow::Result { if versions == [CURRENT_SCHEMA_VERSION] { return Ok(MigrationState::Baseline); } - if versions == [PREVIOUS_RELEASE_SCHEMA_VERSION] { + if versions == [RELEASE_V3_SCHEMA_VERSION] { + return Ok(MigrationState::ReleaseVersion3); + } + if versions == [RELEASE_V2_SCHEMA_VERSION] { + return Ok(MigrationState::ReleaseVersion2); + } + if versions == [RELEASE_V1_SCHEMA_VERSION] { return Ok(MigrationState::ReleaseVersion1); } if latest == OLD_ALPHA_SCHEMA_VERSION { @@ -338,7 +397,7 @@ fn adopt_latest_alpha_schema(conn: &Connection) -> anyhow::Result<()> { INCOMPATIBLE_DATABASE_HINT ); } - reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION)?; migrate_release_v1_to_current(conn) } @@ -365,9 +424,53 @@ CREATE TABLE schema_migrations ( fn migrate_release_v1_to_current(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch(YOUTUBE_EMBEDS_SCHEMA)?; + reset_schema_migrations_to(conn, RELEASE_V2_SCHEMA_VERSION)?; + migrate_release_v2_to_current(conn) +} + +/// Adds the release 1.0.x indexes used by thread assembly, media ownership, +/// session revocation, media reference checks, and rate-limit pruning. +fn migrate_release_v2_to_current(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch(RELEASE_V3_INDEXES)?; + reset_schema_migrations_to(conn, RELEASE_V3_SCHEMA_VERSION)?; + migrate_release_v3_to_current(conn) +} + +/// Adds the account lifecycle columns and tables used by protected accounts, +/// forced password resets, deletion grace periods, username history, instance +/// settings, and account imports. +fn migrate_release_v3_to_current(conn: &Connection) -> anyhow::Result<()> { + for (column, definition) in USER_COLUMNS_ADDED_IN_V4 { + add_column_if_missing(conn, "users", column, definition)?; + } + conn.execute_batch(RELEASE_V4_SCHEMA)?; reset_schema_migrations(conn) } +/// `ALTER TABLE ... ADD COLUMN` is not idempotent, so the migration checks the +/// current table shape first. `PRAGMA table_info` output is safe to inspect +/// because the table names here are compile-time constants. +fn add_column_if_missing( + conn: &Connection, + table: &str, + column: &str, + definition: &str, +) -> anyhow::Result<()> { + let exists = table_columns(conn, table)? + .iter() + .any(|existing| existing.name == column); + if exists { + return Ok(()); + } + conn.execute_batch(&format!( + "ALTER TABLE {} ADD COLUMN {} {};", + quoted_identifier(table), + quoted_identifier(column), + definition + ))?; + Ok(()) +} + fn inspect_schema(conn: &Connection) -> anyhow::Result { let mut issues = Vec::new(); let mut version = None; @@ -421,6 +524,10 @@ fn inspect_schema_objects(conn: &Connection) -> anyhow::Result> { } fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result> { + // Objects introduced after the alpha schema are reported as missing here + // but are created by the migration chain during adoption. The `users` + // column list may also differ because adoption appends the version 4 + // account lifecycle columns. Ok(inspect_schema_objects(conn)? .into_iter() .filter(|issue| { @@ -429,7 +536,22 @@ fn inspect_alpha_schema_objects(conn: &Connection) -> anyhow::Result "missing table post_embeds" | "missing index idx_post_embeds_post" | "missing index idx_post_embeds_video" - ) + | "missing index idx_posts_root" + | "missing index idx_media_owner" + | "missing index idx_sessions_user" + | "missing index idx_post_media_media" + | "missing index idx_rate_limit_created" + | "missing table follow_requests" + | "missing index idx_follow_requests_target" + | "missing table username_history" + | "missing index idx_username_history_normalized" + | "missing table instance_settings" + | "missing table account_imports" + | "missing index idx_account_imports_user" + ) && !issue.starts_with("table users columns differ") + && !USER_COLUMNS_ADDED_IN_V4 + .iter() + .any(|(column, _definition)| issue == &format!("missing column users.{column}")) }) .collect()) } @@ -633,6 +755,16 @@ fn summarize_issues(issues: &[String]) -> String { summary } +/// Schema additions introduced with schema version 3. Applied on top of a +/// version 2 database; every statement is idempotent. +const RELEASE_V3_INDEXES: &str = r#" +CREATE INDEX IF NOT EXISTS idx_posts_root ON posts(root_post_id); +CREATE INDEX IF NOT EXISTS idx_media_owner ON media(owner_user_id); +CREATE INDEX IF NOT EXISTS idx_sessions_user ON sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_post_media_media ON post_media(media_id); +CREATE INDEX IF NOT EXISTS idx_rate_limit_created ON rate_limit_events(created_at); +"#; + const YOUTUBE_EMBEDS_SCHEMA: &str = r#" CREATE TABLE IF NOT EXISTS post_embeds ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -655,13 +787,68 @@ CREATE INDEX IF NOT EXISTS idx_post_embeds_post ON post_embeds(post_id, position CREATE INDEX IF NOT EXISTS idx_post_embeds_video ON post_embeds(provider, video_id); "#; +/// Columns appended to `users` by schema version 4. The order is part of the +/// schema contract: fresh baselines and migrated databases must match it. +const USER_COLUMNS_ADDED_IN_V4: &[(&str, &str)] = &[ + ("follow_approval_required", "INTEGER NOT NULL DEFAULT 0"), + ("must_change_password", "INTEGER NOT NULL DEFAULT 0"), + ("deletion_requested_at", "TEXT"), + ("deletion_scheduled_at", "TEXT"), +]; + +/// Tables and indexes introduced with schema version 4. Applied on top of a +/// version 3 database; every statement is idempotent. +const RELEASE_V4_SCHEMA: &str = r#" +CREATE TABLE IF NOT EXISTS follow_requests ( + requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (requester_id, target_id) +); + +CREATE INDEX IF NOT EXISTS idx_follow_requests_target ON follow_requests(target_id, requester_id); + +CREATE TABLE IF NOT EXISTS username_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, normalized_username) +); + +CREATE INDEX IF NOT EXISTS idx_username_history_normalized ON username_history(normalized_username); + +CREATE TABLE IF NOT EXISTS instance_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE IF NOT EXISTS account_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + archive_id TEXT NOT NULL, + format_version INTEGER NOT NULL, + imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + posts_imported INTEGER NOT NULL DEFAULT 0, + media_imported INTEGER NOT NULL DEFAULT 0, + follows_imported INTEGER NOT NULL DEFAULT 0, + follows_pending INTEGER NOT NULL DEFAULT 0, + follows_skipped INTEGER NOT NULL DEFAULT 0, + UNIQUE (user_id, archive_id) +); + +CREATE INDEX IF NOT EXISTS idx_account_imports_user ON account_imports(user_id, imported_at DESC); +"#; + const BASELINE_SCHEMA: &str = r#" CREATE TABLE schema_migrations ( version INTEGER PRIMARY KEY, applied_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP ); -INSERT INTO schema_migrations (version) VALUES (2); +INSERT INTO schema_migrations (version) VALUES (4); CREATE TABLE users ( id INTEGER PRIMARY KEY AUTOINCREMENT, @@ -683,7 +870,11 @@ CREATE TABLE users ( location TEXT NOT NULL DEFAULT '', nsfw_blur_enabled INTEGER NOT NULL DEFAULT 1, onboarding_completed_at TEXT, - liked_posts_public INTEGER NOT NULL DEFAULT 1 CHECK (liked_posts_public IN (0, 1)) + liked_posts_public INTEGER NOT NULL DEFAULT 1 CHECK (liked_posts_public IN (0, 1)), + follow_approval_required INTEGER NOT NULL DEFAULT 0, + must_change_password INTEGER NOT NULL DEFAULT 0, + deletion_requested_at TEXT, + deletion_scheduled_at TEXT ); CREATE TABLE sessions ( @@ -863,6 +1054,7 @@ END; CREATE INDEX idx_posts_created ON posts(created_at DESC, id DESC); CREATE INDEX idx_posts_user_created ON posts(user_id, created_at DESC, id DESC); CREATE INDEX idx_posts_parent ON posts(parent_post_id, created_at ASC, id ASC); +CREATE INDEX idx_posts_root ON posts(root_post_id); CREATE INDEX idx_follows_followed ON follows(followed_id, follower_id); CREATE INDEX idx_blocks_blocked ON blocks(blocked_id, blocker_id); CREATE INDEX idx_mutes_muted ON mutes(muted_id, muter_id); @@ -893,6 +1085,10 @@ CREATE INDEX idx_notifications_user_id_desc ON notifications(user_id, id DESC); CREATE INDEX idx_notifications_dedupe ON notifications(user_id, actor_user_id, post_id, kind); CREATE INDEX idx_media_is_nsfw ON media(is_nsfw); +CREATE INDEX idx_media_owner ON media(owner_user_id); +CREATE INDEX idx_sessions_user ON sessions(user_id); +CREATE INDEX idx_post_media_media ON post_media(media_id); +CREATE INDEX idx_rate_limit_created ON rate_limit_events(created_at); CREATE INDEX idx_media_original_sha256 ON media(media_kind, original_sha256) @@ -948,6 +1144,48 @@ BEGIN THEN RAISE(ABORT, 'media canonical reference must point to same-kind canonical media') END; END; + +CREATE TABLE follow_requests ( + requester_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + target_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (requester_id, target_id) +); + +CREATE INDEX idx_follow_requests_target ON follow_requests(target_id, requester_id); + +CREATE TABLE username_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL, + changed_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + UNIQUE (user_id, normalized_username) +); + +CREATE INDEX idx_username_history_normalized ON username_history(normalized_username); + +CREATE TABLE instance_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE TABLE account_imports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + archive_id TEXT NOT NULL, + format_version INTEGER NOT NULL, + imported_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP, + posts_imported INTEGER NOT NULL DEFAULT 0, + media_imported INTEGER NOT NULL DEFAULT 0, + follows_imported INTEGER NOT NULL DEFAULT 0, + follows_pending INTEGER NOT NULL DEFAULT 0, + follows_skipped INTEGER NOT NULL DEFAULT 0, + UNIQUE (user_id, archive_id) +); + +CREATE INDEX idx_account_imports_user ON account_imports(user_id, imported_at DESC); "#; const REQUIRED_TABLES: &[&str] = &[ @@ -971,6 +1209,10 @@ const REQUIRED_TABLES: &[&str] = &[ "reports", "posts_fts", "muted_words", + "follow_requests", + "username_history", + "instance_settings", + "account_imports", ]; const FTS_SHADOW_TABLES: &[&str] = &[ @@ -997,6 +1239,31 @@ const REQUIRED_INDEXES: &[RequiredIndex] = &[ name: "idx_posts_parent", unique: false, }, + RequiredIndex { + table: "posts", + name: "idx_posts_root", + unique: false, + }, + RequiredIndex { + table: "media", + name: "idx_media_owner", + unique: false, + }, + RequiredIndex { + table: "sessions", + name: "idx_sessions_user", + unique: false, + }, + RequiredIndex { + table: "post_media", + name: "idx_post_media_media", + unique: false, + }, + RequiredIndex { + table: "rate_limit_events", + name: "idx_rate_limit_created", + unique: false, + }, RequiredIndex { table: "follows", name: "idx_follows_followed", @@ -1102,6 +1369,21 @@ const REQUIRED_INDEXES: &[RequiredIndex] = &[ name: "idx_media_unique_canonical_normalized_sha256", unique: true, }, + RequiredIndex { + table: "follow_requests", + name: "idx_follow_requests_target", + unique: false, + }, + RequiredIndex { + table: "username_history", + name: "idx_username_history_normalized", + unique: false, + }, + RequiredIndex { + table: "account_imports", + name: "idx_account_imports_user", + unique: false, + }, ]; const REQUIRED_TRIGGERS: &[RequiredTrigger] = &[ @@ -1304,6 +1586,38 @@ const REQUIRED_COLUMNS: &[RequiredColumn] = &[ default_value: Some("1"), primary_key_position: 0, }, + RequiredColumn { + table: "users", + name: "follow_approval_required", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "must_change_password", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "deletion_requested_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "users", + name: "deletion_scheduled_at", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 0, + }, RequiredColumn { table: "sessions", name: "id", @@ -2184,170 +2498,954 @@ const REQUIRED_COLUMNS: &[RequiredColumn] = &[ default_value: Some("CURRENT_TIMESTAMP"), primary_key_position: 0, }, -]; - -#[cfg(test)] -mod tests { - use super::*; - - #[tokio::test] - async fn migrations_apply() { - let temp = tempfile::tempdir().expect("temp dir"); - let pool = connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - migrate(&pool).await.expect("migrate"); - let (user_count, version): (i64, i64) = pool - .call(|conn| { - let user_count = - conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; - let version = schema_version_from_connection(conn)?; - Ok((user_count, version)) - }) - .await - .expect("baseline"); - let report = schema_report(&pool).await.expect("schema report"); - assert_eq!(user_count, 0); - assert_eq!(version, CURRENT_SCHEMA_VERSION); - assert!(report.is_compatible(), "{}", report.summary()); - } - - #[tokio::test] - async fn migrations_are_idempotent_and_enable_sqlite_settings() { - let temp = tempfile::tempdir().expect("temp dir"); - let pool = connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - migrate(&pool).await.expect("first migrate"); - migrate(&pool).await.expect("second migrate"); - - let (versions, foreign_keys, journal_mode, busy_timeout): (i64, i64, String, i64) = pool - .call(|conn| { - let versions = - conn.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| { - row.get(0) - })?; - let foreign_keys = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; - let journal_mode = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?; - let busy_timeout = conn.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?; - Ok((versions, foreign_keys, journal_mode, busy_timeout)) - }) - .await - .expect("settings"); - - assert_eq!(versions, 1); - assert_eq!(foreign_keys, 1); - assert_eq!(journal_mode, "wal"); - assert!(busy_timeout >= 5_000); - } - - #[tokio::test] - async fn canonical_media_constraints_reject_unsafe_references() { - let temp = tempfile::tempdir().expect("temp dir"); - let pool = connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - migrate(&pool).await.expect("migrate"); - - pool.call(|conn| { - conn.execute( - "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (10, 'image.webp', '/tmp/image.webp', '/uploads/images/image.webp', 'image/webp', 'image', 1, 'raw', 'norm')", - [], - )?; - let duplicate_canonical = conn.execute( - "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256) VALUES ('copy.webp', '/tmp/copy.webp', '/uploads/images/copy.webp', 'image/webp', 'image', 1, 'raw')", - [], - ); - assert!(duplicate_canonical.is_err()); - - let cross_kind = conn.execute( - "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES ('clip.webm', '/tmp/clip.webm', '/uploads/videos/clip.webm', 'video/webm', 'video', 1, 10)", - [], - ); - assert!(cross_kind.is_err()); - - let self_reference = conn.execute( - "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES (11, 'self.webp', '/tmp/self.webp', '/uploads/images/self.webp', 'image/webp', 'image', 1, 11)", - [], - ); - assert!(self_reference.is_err()); - Ok(()) - }) - .await - .expect("constraint checks"); - } - - #[tokio::test] - async fn users_get_light_theme_by_default() { - let temp = tempfile::tempdir().expect("temp dir"); - let pool = connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - migrate(&pool).await.expect("migrate"); - - let theme: String = pool - .call(|conn| { - conn.execute( - "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", - [], - )?; - Ok(conn.query_row("SELECT theme FROM users WHERE normalized_username = 'alice'", [], |row| row.get(0))?) - }) - .await - .expect("theme"); - - assert_eq!(theme, "light"); - } - - #[tokio::test] - async fn muted_words_table_is_created() { - let temp = tempfile::tempdir().expect("temp dir"); - let pool = connect(&temp.path().join("test.sqlite3")) - .await - .expect("connect"); - migrate(&pool).await.expect("migrate"); - - let count: i64 = pool - .call(|conn| { + RequiredColumn { + table: "follow_requests", + name: "requester_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "follow_requests", + name: "target_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 2, + }, + RequiredColumn { + table: "follow_requests", + name: "created_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "username_history", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "username", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "normalized_username", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "username_history", + name: "changed_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "instance_settings", + name: "key", + type_name: "TEXT", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "instance_settings", + name: "value", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "instance_settings", + name: "updated_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "id", + type_name: "INTEGER", + not_null: false, + default_value: None, + primary_key_position: 1, + }, + RequiredColumn { + table: "account_imports", + name: "user_id", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "archive_id", + type_name: "TEXT", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "format_version", + type_name: "INTEGER", + not_null: true, + default_value: None, + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "imported_at", + type_name: "TEXT", + not_null: true, + default_value: Some("CURRENT_TIMESTAMP"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "posts_imported", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "media_imported", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "follows_imported", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "follows_pending", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, + RequiredColumn { + table: "account_imports", + name: "follows_skipped", + type_name: "INTEGER", + not_null: true, + default_value: Some("0"), + primary_key_position: 0, + }, +]; + +#[cfg(test)] +mod tests { + use super::*; + + #[tokio::test] + async fn migrations_apply() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + let (user_count, version): (i64, i64) = pool + .call(|conn| { + let user_count = + conn.query_row("SELECT COUNT(*) FROM users", [], |row| row.get(0))?; + let version = schema_version_from_connection(conn)?; + Ok((user_count, version)) + }) + .await + .expect("baseline"); + let report = schema_report(&pool).await.expect("schema report"); + assert_eq!(user_count, 0); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert!(report.is_compatible(), "{}", report.summary()); + } + + #[tokio::test] + async fn migrations_are_idempotent_and_enable_sqlite_settings() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("first migrate"); + migrate(&pool).await.expect("second migrate"); + + let (versions, foreign_keys, journal_mode, busy_timeout): (i64, i64, String, i64) = pool + .call(|conn| { + let versions = + conn.query_row("SELECT COUNT(*) FROM schema_migrations", [], |row| { + row.get(0) + })?; + let foreign_keys = conn.query_row("PRAGMA foreign_keys", [], |row| row.get(0))?; + let journal_mode = conn.query_row("PRAGMA journal_mode", [], |row| row.get(0))?; + let busy_timeout = conn.query_row("PRAGMA busy_timeout", [], |row| row.get(0))?; + Ok((versions, foreign_keys, journal_mode, busy_timeout)) + }) + .await + .expect("settings"); + + assert_eq!(versions, 1); + assert_eq!(foreign_keys, 1); + assert_eq!(journal_mode, "wal"); + assert!(busy_timeout >= 5_000); + } + + #[test] + fn fresh_database_has_v4_user_lifecycle_columns_with_defaults() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("foreign keys"); + initialize_release_baseline(&conn).expect("baseline"); + + assert_eq!( + schema_version_from_connection(&conn).expect("schema version"), + CURRENT_SCHEMA_VERSION + ); + + for (column, type_name, not_null, default_value) in [ + ("follow_approval_required", "INTEGER", true, Some("0")), + ("must_change_password", "INTEGER", true, Some("0")), + ("deletion_requested_at", "TEXT", false, None), + ("deletion_scheduled_at", "TEXT", false, None), + ] { + let actual = users_column(&conn, column); + assert_eq!(actual.type_name, type_name, "users.{column} type"); + assert_eq!(actual.not_null, not_null, "users.{column} nullability"); + assert_eq!( + actual.default_value.as_deref(), + default_value, + "users.{column} default" + ); + assert_eq!(actual.primary_key_position, 0, "users.{column} primary key"); + } + + conn.execute( + "INSERT INTO users (id, username, normalized_username, password_hash, display_name) VALUES (1, 'Alice', 'alice', 'hash', 'Alice')", + [], + ) + .expect("user"); + let defaults: (i64, i64, Option, Option) = conn + .query_row( + "SELECT follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = 1", + [], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)), + ) + .expect("defaults"); + assert_eq!(defaults, (0, 0, None, None)); + assert!( + inspect_schema(&conn).expect("schema").is_compatible(), + "fresh baseline must satisfy its own schema requirements" + ); + } + + #[test] + fn fresh_database_has_v4_follow_tables_with_expected_columns() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + + for table in ["follow_requests", "username_history"] { + assert!( + table_exists(&conn, table).expect("table lookup"), + "missing table {table}" + ); + } + + assert_eq!( + column_shapes(&conn, "follow_requests"), + vec![ + ( + "requester_id".to_owned(), + "INTEGER".to_owned(), + true, + None, + 1 + ), + ("target_id".to_owned(), "INTEGER".to_owned(), true, None, 2), + ( + "created_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ] + ); + assert_eq!( + column_shapes(&conn, "username_history"), + vec![ + ("id".to_owned(), "INTEGER".to_owned(), false, None, 1), + ("user_id".to_owned(), "INTEGER".to_owned(), true, None, 0), + ("username".to_owned(), "TEXT".to_owned(), true, None, 0), + ( + "normalized_username".to_owned(), + "TEXT".to_owned(), + true, + None, + 0 + ), + ( + "changed_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ] + ); + } + + #[test] + fn fresh_database_has_v4_instance_tables_with_expected_columns() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + + for table in ["instance_settings", "account_imports"] { + assert!( + table_exists(&conn, table).expect("table lookup"), + "missing table {table}" + ); + } + + assert_eq!( + column_shapes(&conn, "instance_settings"), + vec![ + ("key".to_owned(), "TEXT".to_owned(), false, None, 1), + ("value".to_owned(), "TEXT".to_owned(), true, None, 0), + ( + "updated_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ] + ); + assert_eq!( + column_shapes(&conn, "account_imports"), + vec![ + ("id".to_owned(), "INTEGER".to_owned(), false, None, 1), + ("user_id".to_owned(), "INTEGER".to_owned(), true, None, 0), + ("archive_id".to_owned(), "TEXT".to_owned(), true, None, 0), + ( + "format_version".to_owned(), + "INTEGER".to_owned(), + true, + None, + 0 + ), + ( + "imported_at".to_owned(), + "TEXT".to_owned(), + true, + Some("CURRENT_TIMESTAMP".to_owned()), + 0 + ), + ( + "posts_imported".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "media_imported".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "follows_imported".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "follows_pending".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ( + "follows_skipped".to_owned(), + "INTEGER".to_owned(), + true, + Some("0".to_owned()), + 0 + ), + ] + ); + } + + #[test] + fn fresh_database_enforces_v4_unique_constraints_indexes_and_foreign_keys() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + + assert_eq!( + unique_index_column_sets(&conn, "follow_requests"), + vec![vec!["requester_id".to_owned(), "target_id".to_owned()]] + ); + assert!( + unique_index_column_sets(&conn, "username_history").contains(&vec![ + "user_id".to_owned(), + "normalized_username".to_owned() + ]), + "username_history must keep UNIQUE(user_id, normalized_username)" + ); + assert!( + unique_index_column_sets(&conn, "account_imports") + .contains(&vec!["user_id".to_owned(), "archive_id".to_owned()]), + "account_imports must keep UNIQUE(user_id, archive_id)" + ); + + for (table, name) in [ + ("follow_requests", "idx_follow_requests_target"), + ("username_history", "idx_username_history_normalized"), + ("account_imports", "idx_account_imports_user"), + ] { + assert!( + table_indexes(&conn, table) + .expect("indexes") + .iter() + .any(|(index, unique)| index == name && !unique), + "missing index {name}" + ); + } + assert_eq!( + index_columns(&conn, "idx_follow_requests_target"), + vec!["target_id".to_owned(), "requester_id".to_owned()] + ); + assert_eq!( + index_columns(&conn, "idx_username_history_normalized"), + vec!["normalized_username".to_owned()] + ); + assert_eq!( + index_columns(&conn, "idx_account_imports_user"), + vec!["user_id".to_owned(), "imported_at".to_owned()] + ); + + assert_eq!( + foreign_key_actions(&conn, "follow_requests"), + vec![ + ( + "users".to_owned(), + "requester_id".to_owned(), + "CASCADE".to_owned() + ), + ( + "users".to_owned(), + "target_id".to_owned(), + "CASCADE".to_owned() + ), + ] + ); + assert_eq!( + foreign_key_actions(&conn, "username_history"), + vec![( + "users".to_owned(), + "user_id".to_owned(), + "CASCADE".to_owned() + )] + ); + assert_eq!( + foreign_key_actions(&conn, "account_imports"), + vec![( + "users".to_owned(), + "user_id".to_owned(), + "CASCADE".to_owned() + )] + ); + } + + #[test] + fn fresh_database_v4_rows_cascade_when_a_user_is_deleted() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + conn.pragma_update(None, "foreign_keys", "ON") + .expect("foreign keys"); + initialize_release_baseline(&conn).expect("baseline"); + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + INSERT INTO follow_requests (requester_id, target_id) VALUES (1, 2); + INSERT INTO username_history (user_id, username, normalized_username) VALUES (2, 'Bobby', 'bobby'); + INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (2, 'archive-1', 1); + "#, + ) + .expect("seed rows"); + + conn.execute("DELETE FROM users WHERE id = 2", []) + .expect("delete user"); + + assert_eq!( + table_count(&conn, "follow_requests").expect("follow requests"), + 0 + ); + assert_eq!( + table_count(&conn, "username_history").expect("username history"), + 0 + ); + assert_eq!( + table_count(&conn, "account_imports").expect("account imports"), + 0 + ); + } + + #[test] + fn fresh_database_rejects_duplicate_v4_rows() { + let temp = tempfile::tempdir().expect("temp dir"); + let conn = Connection::open(temp.path().join("fresh.sqlite3")).expect("open"); + initialize_release_baseline(&conn).expect("baseline"); + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob'); + "#, + ) + .expect("users"); + + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (1, 2)", + [], + ) + .expect("first follow request"); + assert!( + conn.execute( + "INSERT INTO follow_requests (requester_id, target_id) VALUES (1, 2)", + [], + ) + .is_err(), + "duplicate follow requests must be rejected" + ); + + conn.execute( + "INSERT INTO username_history (user_id, username, normalized_username) VALUES (1, 'OldAlice', 'oldalice')", + [], + ) + .expect("first history row"); + assert!( + conn.execute( + "INSERT INTO username_history (user_id, username, normalized_username) VALUES (1, 'OlderAlice', 'oldalice')", + [], + ) + .is_err(), + "duplicate username history rows for one account must be rejected" + ); + + conn.execute( + "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (1, 'archive-1', 1)", + [], + ) + .expect("first import"); + assert!( + conn.execute( + "INSERT INTO account_imports (user_id, archive_id, format_version) VALUES (1, 'archive-1', 2)", + [], + ) + .is_err(), + "duplicate account imports for one archive must be rejected" + ); + + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('announcement', 'hi')", + [], + ) + .expect("first instance setting"); + assert!( + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('announcement', 'again')", + [], + ) + .is_err(), + "duplicate instance setting keys must be rejected" + ); + } + + #[tokio::test] + async fn canonical_media_constraints_reject_unsafe_references() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + pool.call(|conn| { + conn.execute( + "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256, normalized_sha256) VALUES (10, 'image.webp', '/tmp/image.webp', '/uploads/images/image.webp', 'image/webp', 'image', 1, 'raw', 'norm')", + [], + )?; + let duplicate_canonical = conn.execute( + "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256) VALUES ('copy.webp', '/tmp/copy.webp', '/uploads/images/copy.webp', 'image/webp', 'image', 1, 'raw')", + [], + ); + assert!(duplicate_canonical.is_err()); + + let cross_kind = conn.execute( + "INSERT INTO media (original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES ('clip.webm', '/tmp/clip.webm', '/uploads/videos/clip.webm', 'video/webm', 'video', 1, 10)", + [], + ); + assert!(cross_kind.is_err()); + + let self_reference = conn.execute( + "INSERT INTO media (id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, canonical_media_id) VALUES (11, 'self.webp', '/tmp/self.webp', '/uploads/images/self.webp', 'image/webp', 'image', 1, 11)", + [], + ); + assert!(self_reference.is_err()); + Ok(()) + }) + .await + .expect("constraint checks"); + } + + #[tokio::test] + async fn users_get_light_theme_by_default() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + let theme: String = pool + .call(|conn| { + conn.execute( + "INSERT INTO users (username, normalized_username, password_hash, display_name) VALUES ('Alice', 'alice', 'hash', 'Alice')", + [], + )?; + Ok(conn.query_row("SELECT theme FROM users WHERE normalized_username = 'alice'", [], |row| row.get(0))?) + }) + .await + .expect("theme"); + + assert_eq!(theme, "light"); + } + + #[tokio::test] + async fn muted_words_table_is_created() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + migrate(&pool).await.expect("migrate"); + + let count: i64 = pool + .call(|conn| { Ok(conn.query_row("SELECT COUNT(*) FROM muted_words", [], |row| row.get(0))?) }) .await - .expect("count"); + .expect("count"); + + assert_eq!(count, 0); + } + + #[tokio::test] + async fn release_version_1_databases_migrate_to_post_embeds() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v1_fixture(conn)?; + insert_legacy_migration_fixture(conn) + }) + .await + .expect("version 1 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + assert_release_upgrade_preserves_data(&pool, legacy_before).await; + + let (embed_count, version) = pool + .call(|conn| { + Ok(( + table_count(conn, "post_embeds")?, + schema_version_from_connection(conn)?, + )) + }) + .await + .expect("post embeds"); - assert_eq!(count, 0); + assert_eq!(embed_count, 0); + assert_eq!(version, CURRENT_SCHEMA_VERSION); } #[tokio::test] - async fn release_version_1_databases_migrate_to_post_embeds() { + async fn release_version_2_databases_gain_release_indexes_without_data_loss() { let temp = tempfile::tempdir().expect("temp dir"); let pool = connect(&temp.path().join("test.sqlite3")) .await .expect("connect"); pool.call(|conn| { - initialize_release_baseline(conn)?; - conn.execute("DROP TABLE post_embeds", [])?; - reset_schema_migrations_to(conn, PREVIOUS_RELEASE_SCHEMA_VERSION)?; + install_release_v2_fixture(conn)?; + insert_legacy_migration_fixture(conn) + }) + .await + .expect("version 2 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + assert_release_upgrade_preserves_data(&pool, legacy_before).await; + + let root_index = pool + .call(|conn| { + Ok(table_indexes(conn, "posts")? + .iter() + .any(|(name, _unique)| name == "idx_posts_root")) + }) + .await + .expect("release index"); + + assert!(root_index, "version 3 migration must add idx_posts_root"); + } + + #[tokio::test] + async fn release_version_3_databases_gain_account_lifecycle_state_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v3_fixture(conn)?; + insert_legacy_migration_fixture(conn) + }) + .await + .expect("version 3 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + assert_release_upgrade_preserves_data(&pool, legacy_before).await; + } + + #[tokio::test] + async fn release_version_3_wrong_type_v4_column_is_rejected_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v3_fixture(conn)?; + insert_legacy_migration_fixture(conn)?; + conn.execute_batch( + r#" + ALTER TABLE users ADD COLUMN follow_approval_required TEXT; + "#, + )?; Ok(()) }) .await - .expect("version 1 fixture"); + .expect("wrong-typed version 3 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); + + let error = migrate(&pool) + .await + .expect_err("wrong column type must be rejected"); + let message = format!("{error:#}"); + let (version, legacy_after, v4_tables) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + legacy_rows(conn)?, + existing_v4_tables(conn), + )) + }) + .await + .expect("state after rejected migration"); + + assert!( + message.contains("users.follow_approval_required"), + "unexpected error: {message}" + ); + assert!( + message.contains("expected INTEGER"), + "unexpected error: {message}" + ); + assert_eq!(version, RELEASE_V3_SCHEMA_VERSION); + assert_eq!(legacy_after, legacy_before); + assert!(v4_tables.is_empty(), "rolled back tables: {v4_tables:?}"); + } - migrate(&pool).await.expect("migrate version 1"); + #[tokio::test] + async fn release_version_3_partial_v4_table_is_rejected_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_release_v3_fixture(conn)?; + insert_legacy_migration_fixture(conn)?; + conn.execute_batch( + r#" + CREATE TABLE username_history ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, + username TEXT NOT NULL, + normalized_username TEXT NOT NULL + ); + "#, + )?; + Ok(()) + }) + .await + .expect("partial version 3 fixture"); + let legacy_before = pool + .call(|conn| legacy_rows(conn)) + .await + .expect("legacy rows"); - let (embed_count, version): (i64, i64) = pool + let error = migrate(&pool) + .await + .expect_err("partial v4 table must be rejected"); + let message = format!("{error:#}"); + let (version, legacy_after, user_columns, follow_requests, history_rows) = pool .call(|conn| { Ok(( - conn.query_row("SELECT COUNT(*) FROM post_embeds", [], |row| row.get(0))?, schema_version_from_connection(conn)?, + legacy_rows(conn)?, + table_columns(conn, "users")? + .into_iter() + .map(|column| column.name) + .collect::>(), + table_exists(conn, "follow_requests")?, + table_count(conn, "username_history")?, )) }) .await - .expect("post embeds"); - let report = schema_report(&pool).await.expect("schema report"); + .expect("state after rejected migration"); + + assert!( + message.contains("table username_history columns differ"), + "unexpected error: {message}" + ); + assert!( + message.contains("changed_at"), + "unexpected error: {message}" + ); + assert_eq!(version, RELEASE_V3_SCHEMA_VERSION); + assert_eq!(legacy_after, legacy_before); + assert!( + !user_columns + .iter() + .any(|column| column == "follow_approval_required"), + "v4 user columns must be rolled back: {user_columns:?}" + ); + assert!( + !follow_requests, + "v4 tables created during the failed migration must be rolled back" + ); + assert_eq!(history_rows, 0); + } + + #[tokio::test] + async fn alpha_schema_missing_v4_account_lifecycle_state_is_adopted_without_data_loss() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_latest_alpha_fixture(conn)?; + insert_preservation_fixture(conn)?; + for (column, _definition) in USER_COLUMNS_ADDED_IN_V4 { + conn.execute_batch(&format!( + "ALTER TABLE users DROP COLUMN {};", + quoted_identifier(column) + ))?; + } + conn.execute_batch( + r#" + DROP TABLE follow_requests; + DROP TABLE username_history; + DROP TABLE instance_settings; + DROP TABLE account_imports; + "#, + )?; + Ok(()) + }) + .await + .expect("alpha fixture without v4 account lifecycle state"); + + migrate(&pool).await.expect("adopt alpha schema"); + + let (version, presence, users, posts, media, defaults) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + v4_presence(conn)?, + table_count(conn, "users")?, + table_count(conn, "posts")?, + table_count(conn, "media")?, + conn.query_row( + "SELECT follow_approval_required, must_change_password FROM users WHERE id = 1", + [], + |row| Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)), + )?, + )) + }) + .await + .expect("adopted state"); - assert_eq!(embed_count, 0); assert_eq!(version, CURRENT_SCHEMA_VERSION); - assert!(report.is_compatible(), "{}", report.summary()); + assert_eq!(presence.tables.len(), 4); + assert!(presence.user_columns.ends_with(&[ + "follow_approval_required".to_owned(), + "must_change_password".to_owned(), + "deletion_requested_at".to_owned(), + "deletion_scheduled_at".to_owned(), + ])); + assert_eq!((users, posts, media), (2, 2, 1)); + assert_eq!(defaults, (0, 0)); } #[tokio::test] @@ -2460,6 +3558,36 @@ mod tests { assert_eq!(liked_posts_public, 1); } + #[tokio::test] + async fn alpha_schema_missing_release_objects_is_adopted_and_backfilled() { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + pool.call(|conn| { + install_latest_alpha_fixture(conn)?; + conn.execute("DROP TABLE post_embeds", [])?; + for index in [ + "idx_posts_root", + "idx_media_owner", + "idx_sessions_user", + "idx_post_media_media", + "idx_rate_limit_created", + ] { + conn.execute(&format!("DROP INDEX {index}"), [])?; + } + Ok(()) + }) + .await + .expect("alpha fixture without release objects"); + + migrate(&pool).await.expect("adopt alpha schema"); + let report = schema_report(&pool).await.expect("schema report"); + + assert_eq!(report.version(), Some(CURRENT_SCHEMA_VERSION)); + assert!(report.is_compatible(), "{}", report.summary()); + } + #[tokio::test] async fn latest_alpha_schema_is_marked_as_baseline_without_data_loss() { let temp = tempfile::tempdir().expect("temp dir"); @@ -2649,7 +3777,9 @@ mod tests { let conn = Connection::open(&path).expect("open"); let version = schema_version_from_connection(&conn).expect("version"); - assert!(summary.contains("release baseline schema version 2")); + assert!(summary.contains(&format!( + "release baseline schema version {CURRENT_SCHEMA_VERSION}" + ))); assert!(!summary.contains("version 13")); assert_eq!(version, OLD_ALPHA_SCHEMA_VERSION); } @@ -2728,6 +3858,407 @@ mod tests { assert_eq!(social_count, 5); } + fn users_column(conn: &Connection, name: &str) -> ColumnInfo { + table_columns(conn, "users") + .expect("users columns") + .into_iter() + .find(|column| column.name == name) + .unwrap_or_else(|| panic!("missing users.{name}")) + } + + fn column_shapes( + conn: &Connection, + table: &str, + ) -> Vec<(String, String, bool, Option, i64)> { + table_columns(conn, table) + .expect("table columns") + .into_iter() + .map(|column| { + ( + column.name, + column.type_name, + column.not_null, + column.default_value, + column.primary_key_position, + ) + }) + .collect() + } + + fn unique_index_column_sets(conn: &Connection, table: &str) -> Vec> { + let mut sets = Vec::new(); + for (name, unique) in table_indexes(conn, table).expect("indexes") { + if unique { + sets.push(index_columns(conn, &name)); + } + } + sets + } + + fn index_columns(conn: &Connection, name: &str) -> Vec { + let sql = format!("PRAGMA index_info({})", quoted_identifier(name)); + let mut stmt = conn.prepare(&sql).expect("index info"); + stmt.query_map([], |row| row.get::<_, String>(2)) + .expect("index columns") + .collect::>>() + .expect("index column rows") + } + + fn foreign_key_actions(conn: &Connection, table: &str) -> Vec<(String, String, String)> { + let sql = format!("PRAGMA foreign_key_list({})", quoted_identifier(table)); + let mut stmt = conn.prepare(&sql).expect("foreign key list"); + let mut actions = stmt + .query_map([], |row| { + Ok(( + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(6)?, + )) + }) + .expect("foreign keys") + .collect::>>() + .expect("foreign key rows"); + actions.sort(); + actions + } + + fn install_release_v3_fixture(conn: &Connection) -> anyhow::Result<()> { + initialize_release_baseline(conn)?; + for (column, _definition) in USER_COLUMNS_ADDED_IN_V4 { + conn.execute_batch(&format!( + "ALTER TABLE users DROP COLUMN {};", + quoted_identifier(column) + ))?; + } + conn.execute_batch( + r#" + DROP TABLE follow_requests; + DROP TABLE username_history; + DROP TABLE instance_settings; + DROP TABLE account_imports; + "#, + )?; + reset_schema_migrations_to(conn, RELEASE_V3_SCHEMA_VERSION) + } + + fn install_release_v2_fixture(conn: &Connection) -> anyhow::Result<()> { + install_release_v3_fixture(conn)?; + for index in [ + "idx_posts_root", + "idx_media_owner", + "idx_sessions_user", + "idx_post_media_media", + "idx_rate_limit_created", + ] { + conn.execute(&format!("DROP INDEX {index}"), [])?; + } + reset_schema_migrations_to(conn, RELEASE_V2_SCHEMA_VERSION) + } + + fn install_release_v1_fixture(conn: &Connection) -> anyhow::Result<()> { + install_release_v2_fixture(conn)?; + conn.execute_batch("DROP TABLE post_embeds;")?; + reset_schema_migrations_to(conn, RELEASE_V1_SCHEMA_VERSION) + } + + fn insert_legacy_migration_fixture(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + INSERT INTO users (id, username, normalized_username, password_hash, display_name, created_at, updated_at) + VALUES (1, 'Alice', 'alice', 'hash', 'Alice', '2024-01-01T00:00:00Z', '2024-01-01T00:00:00Z'); + INSERT INTO users (id, username, normalized_username, password_hash, display_name, created_at, updated_at) + VALUES (2, 'Bob', 'bob', 'hash', 'Bob', '2024-01-02T00:00:00Z', '2024-01-02T00:00:00Z'); + INSERT INTO posts (id, user_id, text, created_at) + VALUES (1, 1, 'alpha post', '2024-01-03T00:00:00Z'); + INSERT INTO posts (id, user_id, text, created_at) + VALUES (2, 2, 'beta post', '2024-01-04T00:00:00Z'); + INSERT INTO media (id, owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, original_sha256) + VALUES (1, 1, 'alpha.png', '/data/uploads/images/alpha.webp', '/uploads/images/alpha.webp', 'image/webp', 'image', 42, 'legacy-sha'); + "#, + )?; + Ok(()) + } + + fn insert_v4_account_lifecycle_fixture(conn: &Connection) -> anyhow::Result<()> { + conn.execute_batch( + r#" + UPDATE users SET + follow_approval_required = 1, + must_change_password = 1, + deletion_requested_at = '2026-01-01T00:00:00Z', + deletion_scheduled_at = '2026-02-01T00:00:00Z' + WHERE id = 1; + INSERT INTO follow_requests (requester_id, target_id, created_at) + VALUES (1, 2, '2026-01-02T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OldAlice', 'oldalice', '2026-01-03T00:00:00Z'); + INSERT INTO username_history (user_id, username, normalized_username, changed_at) + VALUES (1, 'OlderAlice', 'olderalice', '2026-01-04T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('announcement', 'hello', '2026-01-05T00:00:00Z'); + INSERT INTO instance_settings (key, value, updated_at) + VALUES ('maintenance_mode', '1', '2026-01-06T00:00:00Z'); + INSERT INTO account_imports ( + user_id, archive_id, format_version, imported_at, + posts_imported, media_imported, follows_imported, follows_pending, follows_skipped + ) + VALUES (1, 'archive-1', 2, '2026-01-07T00:00:00Z', 3, 4, 5, 6, 7); + "#, + )?; + Ok(()) + } + + #[derive(Debug, PartialEq, Eq)] + struct LegacyRows { + users: Vec<(i64, String, String, String)>, + posts: Vec<(i64, String, String)>, + media: Vec<(i64, String, String)>, + } + + fn legacy_rows(conn: &Connection) -> anyhow::Result { + let mut users_stmt = + conn.prepare("SELECT id, username, display_name, created_at FROM users ORDER BY id")?; + let users = users_stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>>()?; + let mut posts_stmt = conn.prepare("SELECT id, text, created_at FROM posts ORDER BY id")?; + let posts = posts_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + let mut media_stmt = + conn.prepare("SELECT id, stored_path, original_sha256 FROM media ORDER BY id")?; + let media = media_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + Ok(LegacyRows { + users, + posts, + media, + }) + } + + type UserLifecycleRow = (i64, i64, i64, Option, Option); + type AccountImportRow = (i64, String, i64, String, i64, i64, i64, i64, i64); + + #[derive(Debug, PartialEq, Eq)] + struct AccountLifecycleRows { + users: Vec, + follow_requests: Vec<(i64, i64, String)>, + username_history: Vec<(i64, String, String, String)>, + instance_settings: Vec<(String, String, String)>, + account_imports: Vec, + } + + fn account_lifecycle_rows(conn: &Connection) -> anyhow::Result { + let mut users_stmt = conn.prepare( + "SELECT id, follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users ORDER BY id", + )?; + let users = users_stmt + .query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + )) + })? + .collect::>>()?; + let mut follow_stmt = conn.prepare( + "SELECT requester_id, target_id, created_at FROM follow_requests ORDER BY requester_id, target_id", + )?; + let follow_requests = follow_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + let mut history_stmt = conn.prepare( + "SELECT user_id, username, normalized_username, changed_at FROM username_history ORDER BY user_id, normalized_username", + )?; + let username_history = history_stmt + .query_map([], |row| { + Ok((row.get(0)?, row.get(1)?, row.get(2)?, row.get(3)?)) + })? + .collect::>>()?; + let mut settings_stmt = + conn.prepare("SELECT key, value, updated_at FROM instance_settings ORDER BY key")?; + let instance_settings = settings_stmt + .query_map([], |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)))? + .collect::>>()?; + let mut imports_stmt = conn.prepare( + "SELECT user_id, archive_id, format_version, imported_at, posts_imported, media_imported, follows_imported, follows_pending, follows_skipped FROM account_imports ORDER BY id", + )?; + let account_imports = imports_stmt + .query_map([], |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + row.get(6)?, + row.get(7)?, + row.get(8)?, + )) + })? + .collect::>>()?; + Ok(AccountLifecycleRows { + users, + follow_requests, + username_history, + instance_settings, + account_imports, + }) + } + + #[derive(Debug, PartialEq, Eq)] + struct V4Presence { + tables: Vec<&'static str>, + user_columns: Vec, + indexes: Vec<&'static str>, + } + + fn v4_presence(conn: &Connection) -> anyhow::Result { + let mut tables = Vec::new(); + for table in [ + "follow_requests", + "username_history", + "instance_settings", + "account_imports", + ] { + if table_exists(conn, table)? { + tables.push(table); + } + } + let user_columns = table_columns(conn, "users")? + .into_iter() + .map(|column| column.name) + .collect(); + let mut indexes = Vec::new(); + for (table, name) in [ + ("follow_requests", "idx_follow_requests_target"), + ("username_history", "idx_username_history_normalized"), + ("account_imports", "idx_account_imports_user"), + ] { + if table_indexes(conn, table)? + .iter() + .any(|(index, unique)| index == name && !unique) + { + indexes.push(name); + } + } + Ok(V4Presence { + tables, + user_columns, + indexes, + }) + } + + fn existing_v4_tables(conn: &Connection) -> Vec<&'static str> { + let mut tables = Vec::new(); + for table in [ + "follow_requests", + "username_history", + "instance_settings", + "account_imports", + ] { + if table_exists(conn, table).expect("table lookup") { + tables.push(table); + } + } + tables + } + + async fn assert_release_upgrade_preserves_data(pool: &SqlitePool, legacy_before: LegacyRows) { + migrate(pool) + .await + .expect("migrate historical release schema"); + + let (issues, version, presence, defaults, legacy_after) = pool + .call(|conn| { + Ok(( + inspect_schema(conn)?.issues().to_vec(), + schema_version_from_connection(conn)?, + v4_presence(conn)?, + conn.query_row( + "SELECT follow_approval_required, must_change_password, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = 1", + [], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + )) + }, + )?, + legacy_rows(conn)?, + )) + }) + .await + .expect("post-migration state"); + + assert!(issues.is_empty(), "{}", issues.join("; ")); + assert_eq!(version, CURRENT_SCHEMA_VERSION); + assert_eq!( + presence.tables, + vec![ + "follow_requests", + "username_history", + "instance_settings", + "account_imports" + ] + ); + assert!( + presence.user_columns.ends_with(&[ + "follow_approval_required".to_owned(), + "must_change_password".to_owned(), + "deletion_requested_at".to_owned(), + "deletion_scheduled_at".to_owned(), + ]), + "missing v4 users columns: {:?}", + presence.user_columns + ); + assert_eq!( + presence.indexes, + vec![ + "idx_follow_requests_target", + "idx_username_history_normalized", + "idx_account_imports_user" + ] + ); + assert_eq!(defaults, (0, 0, None, None)); + assert_eq!(legacy_after, legacy_before); + + pool.call(|conn| insert_v4_account_lifecycle_fixture(conn)) + .await + .expect("v4 rows"); + let lifecycle_before = pool + .call(|conn| account_lifecycle_rows(conn)) + .await + .expect("lifecycle rows"); + + migrate(pool) + .await + .expect("second migrate must be idempotent"); + + let (version_again, lifecycle_after, legacy_again) = pool + .call(|conn| { + Ok(( + schema_version_from_connection(conn)?, + account_lifecycle_rows(conn)?, + legacy_rows(conn)?, + )) + }) + .await + .expect("idempotent state"); + + assert_eq!(version_again, CURRENT_SCHEMA_VERSION); + assert_eq!(lifecycle_after, lifecycle_before); + assert_eq!(legacy_again, legacy_before); + } + fn install_latest_alpha_fixture(conn: &Connection) -> anyhow::Result<()> { conn.execute_batch(BASELINE_SCHEMA)?; conn.execute("DELETE FROM schema_migrations", [])?; diff --git a/src/demo_seed.rs b/src/demo_seed.rs index 005175a..35a82ea 100644 --- a/src/demo_seed.rs +++ b/src/demo_seed.rs @@ -253,7 +253,9 @@ async fn create_social_graph(pool: &SqlitePool, ids: &DemoIds) -> anyhow::Result (ids.omar, ids.ada), (ids.omar, ids.tess), ] { - social::follow(pool, follower, followed).await?; + // The seeding script only cares that the relationship is recorded, + // whether the follow is immediate or pending approval. + let _outcome = social::follow(pool, follower, followed).await?; } Ok(()) } diff --git a/src/errors.rs b/src/errors.rs index def9ce4..867af01 100644 --- a/src/errors.rs +++ b/src/errors.rs @@ -10,6 +10,8 @@ pub enum AppError { #[error("forbidden")] Forbidden, #[error("{0}")] + PayloadTooLarge(String), + #[error("{0}")] RateLimited(String), #[error("not found")] NotFound, @@ -32,6 +34,7 @@ impl IntoResponse for AppError { "authentication required".to_owned(), ), Self::Forbidden => (StatusCode::FORBIDDEN, "forbidden".to_owned()), + Self::PayloadTooLarge(message) => (StatusCode::PAYLOAD_TOO_LARGE, message), Self::RateLimited(message) => (StatusCode::TOO_MANY_REQUESTS, message), Self::NotFound => (StatusCode::NOT_FOUND, "not found".to_owned()), Self::Io(_) | Self::Sqlite(_) | Self::Anyhow(_) => { diff --git a/src/ffmpeg.rs b/src/ffmpeg.rs index e315390..361dbd2 100644 --- a/src/ffmpeg.rs +++ b/src/ffmpeg.rs @@ -84,7 +84,7 @@ pub async fn convert_image_to_webp( output: &Path, ) -> anyhow::Result { let args = image_webp_args(settings, input, output); - let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_secs(120)).await?; + let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_mins(2)).await?; if result.status.success() { Ok(stderr_summary(&result.stderr)) } else { @@ -99,7 +99,7 @@ pub async fn convert_image_to_webp_thumbnail( size: u16, ) -> anyhow::Result { let args = image_webp_thumbnail_args(settings, input, output, size); - let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_secs(120)).await?; + let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_mins(2)).await?; if result.status.success() { Ok(stderr_summary(&result.stderr)) } else { @@ -113,7 +113,7 @@ pub async fn convert_video_to_webm( output: &Path, ) -> anyhow::Result { let args = video_webm_args(settings, input, output); - let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_secs(300)).await?; + let result = run_ffmpeg(&settings.ffmpeg_path, &args, Duration::from_mins(5)).await?; if result.status.success() { Ok(stderr_summary(&result.stderr)) } else { @@ -290,12 +290,20 @@ mod tests { #[tokio::test] async fn ffmpeg_timeout_is_reported() { - let result = super::run_ffmpeg( - "sh", - &["-c".to_owned(), "sleep 2".to_owned()], - std::time::Duration::from_millis(50), - ) - .await; + #[cfg(windows)] + let (program, args) = ( + "powershell.exe", + vec![ + "-NoProfile".to_owned(), + "-NonInteractive".to_owned(), + "-Command".to_owned(), + "Start-Sleep -Seconds 2".to_owned(), + ], + ); + #[cfg(not(windows))] + let (program, args) = ("sh", vec!["-c".to_owned(), "sleep 2".to_owned()]); + + let result = super::run_ffmpeg(program, &args, std::time::Duration::from_millis(50)).await; assert!(result.is_err()); assert!( result diff --git a/src/identity.rs b/src/identity.rs new file mode 100644 index 0000000..34f079f --- /dev/null +++ b/src/identity.rs @@ -0,0 +1,1012 @@ +//! Account lifecycle operations: administrator-forced password resets, forced +//! session revocation, and username changes with history. +//! +//! The functions here intentionally keep callers on the existing +//! authentication, auditing, and validation paths. They are used by HTTP +//! handlers in [`crate::server`] and by the account settings pages. + +use rusqlite::{OptionalExtension as _, params}; + +use crate::config::Settings; +use crate::db::SqlitePool; +use crate::validation; + +/// `SQLite` extended result code for a `UNIQUE` constraint failure. +const SQLITE_CONSTRAINT_UNIQUE: i32 = 2067; + +fn is_unique_violation(error: &rusqlite::Error) -> bool { + matches!( + error, + rusqlite::Error::SqliteFailure(code, _) if code.extended_code == SQLITE_CONSTRAINT_UNIQUE + ) +} + +/// Returned when a username change cannot be applied. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum UsernameChangeError { + /// The handle is already used by another account, now or in the past. + Taken, + /// The handle fails the existing username rules. + Invalid, + /// The account does not exist or is marked deleted. + NotFound, + /// The requested handle is already the account's current handle. + Unchanged, +} + +impl std::fmt::Display for UsernameChangeError { + fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::Taken => formatter.write_str("username is already taken"), + Self::Invalid => formatter.write_str("username is invalid"), + Self::NotFound => formatter.write_str("account not found"), + Self::Unchanged => formatter.write_str("that is already your username"), + } + } +} + +impl std::error::Error for UsernameChangeError {} + +/// A completed username change. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsernameChange { + pub previous_username: String, + pub username: String, +} + +/// One previously held handle for an account. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct UsernameHistoryEntry { + pub username: String, + pub changed_at: String, +} + +/// One account that previously held a handle that no current account owns. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct HistoricalUsernameHolder { + pub user_id: i64, + pub username: String, + pub display_name: String, + pub changed_at: String, +} + +/// Marks an account as requiring a password change before it can use anything +/// other than the password-change and logout flows, and writes an audit row. +pub async fn require_password_reset( + pool: &SqlitePool, + admin_user_id: i64, + user_id: i64, +) -> anyhow::Result<()> { + let updated = pool + .call(move |conn| { + Ok(conn.execute( + "UPDATE users SET must_change_password = 1, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + [user_id], + )?) + }) + .await?; + if updated == 0 { + anyhow::bail!("account not found"); + } + crate::admin::audit( + pool, + admin_user_id, + "require_password_reset", + &format!("user:{user_id}"), + ) + .await +} + +/// Revokes every active session belonging to one account and writes an audit +/// row. Returns the number of sessions revoked. +pub async fn revoke_user_sessions( + pool: &SqlitePool, + admin_user_id: i64, + user_id: i64, +) -> anyhow::Result { + // The account check and the revocation share one transaction so a + // concurrent deletion cannot slip between them and turn the documented + // "account not found" rejection into a silent no-op success. + let revoked = pool + .call(move |conn| { + let tx = conn.transaction()?; + let exists = tx + .query_row( + "SELECT 1 FROM users WHERE id = ? AND is_deleted = 0", + [user_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !exists { + return Ok(None); + } + let revoked = tx.execute( + "UPDATE sessions SET revoked_at = CURRENT_TIMESTAMP WHERE user_id = ? AND revoked_at IS NULL", + [user_id], + )?; + tx.commit()?; + Ok(Some(revoked)) + }) + .await?; + let Some(revoked) = revoked else { + anyhow::bail!("account not found"); + }; + crate::admin::audit( + pool, + admin_user_id, + "revoke_user_sessions", + &format!("user:{user_id}"), + ) + .await?; + Ok(revoked) +} + +/// Changes an account's handle atomically. +/// +/// The previous handle is recorded in `username_history` in the same +/// transaction. Reclaiming a handle the same account previously held is +/// allowed and removes the stale history row that matches the new handle. +pub async fn change_username( + pool: &SqlitePool, + settings: &Settings, + user_id: i64, + new_username: &str, +) -> Result { + let normalized = + validation::normalize_username(new_username, settings.accounts.max_username_len) + .map_err(|_invalid| UsernameChangeError::Invalid)?; + let display_username = new_username.trim().to_owned(); + let result = pool + .call(move |conn| { + let tx = conn.transaction()?; + let current = tx + .query_row( + "SELECT username, normalized_username, is_deleted FROM users WHERE id = ?", + [user_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, i64>(2)?, + )) + }, + ) + .optional()?; + let Some((previous_username, current_normalized, is_deleted)) = current else { + return Ok(Err(UsernameChangeError::NotFound)); + }; + if is_deleted != 0 { + return Ok(Err(UsernameChangeError::NotFound)); + } + if current_normalized == normalized { + return Ok(Err(UsernameChangeError::Unchanged)); + } + let current_owner = tx + .query_row( + "SELECT 1 FROM users WHERE normalized_username = ? AND id != ?", + params![normalized, user_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if current_owner { + return Ok(Err(UsernameChangeError::Taken)); + } + let history_owner = tx + .query_row( + "SELECT user_id FROM username_history WHERE normalized_username = ? AND user_id != ? LIMIT 1", + params![normalized, user_id], + |row| row.get::<_, i64>(0), + ) + .optional()?; + if history_owner.is_some() { + return Ok(Err(UsernameChangeError::Taken)); + } + tx.execute( + "DELETE FROM username_history WHERE user_id = ? AND normalized_username = ?", + params![user_id, normalized], + )?; + tx.execute( + "INSERT INTO username_history (user_id, username, normalized_username, changed_at) VALUES (?, ?, ?, CURRENT_TIMESTAMP)", + params![user_id, previous_username, current_normalized], + )?; + match tx.execute( + "UPDATE users SET username = ?, normalized_username = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ? AND is_deleted = 0", + params![display_username, normalized, user_id], + ) { + Ok(_) => {} + Err(error) if is_unique_violation(&error) => { + return Ok(Err(UsernameChangeError::Taken)); + } + Err(error) => return Err(error.into()), + } + match tx.commit() { + Ok(()) => {} + Err(error) if is_unique_violation(&error) => { + return Ok(Err(UsernameChangeError::Taken)); + } + Err(error) => return Err(error.into()), + } + Ok(Ok(UsernameChange { + previous_username, + username: display_username, + })) + }) + .await; + match result { + Ok(inner) => inner, + Err(error) => { + tracing::error!(user_id, error = %error, "username change failed"); + Err(UsernameChangeError::Invalid) + } + } +} + +/// Historically held handles for one account, newest first. +pub async fn username_history( + pool: &SqlitePool, + user_id: i64, +) -> anyhow::Result> { + pool.call(move |conn| { + let mut stmt = conn.prepare( + "SELECT username, changed_at FROM username_history WHERE user_id = ? ORDER BY changed_at DESC, id DESC", + )?; + let rows = stmt + .query_map([user_id], |row| { + Ok(UsernameHistoryEntry { + username: row.get(0)?, + changed_at: row.get(1)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await +} + +/// Resolves a normalized handle that no current account owns to the accounts +/// that previously held it, newest first. +pub async fn historical_username_holders( + pool: &SqlitePool, + normalized_username: &str, +) -> anyhow::Result> { + let normalized_username = normalized_username.to_owned(); + pool.call(move |conn| { + let mut stmt = conn.prepare( + r#" + SELECT u.id, u.username, u.display_name, h.changed_at + FROM username_history h + JOIN users u ON u.id = h.user_id + WHERE h.normalized_username = ? AND u.is_deleted = 0 + ORDER BY h.changed_at DESC + "#, + )?; + let rows = stmt + .query_map([normalized_username], |row| { + Ok(HistoricalUsernameHolder { + user_id: row.get(0)?, + username: row.get(1)?, + display_name: row.get(2)?, + changed_at: row.get(3)?, + }) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await +} + +/// Normalizes a candidate handle with the shared username rules. +/// +/// Exposed so callers can validate without duplicating the rules. +pub fn normalize_candidate_username( + settings: &Settings, + candidate: &str, +) -> Result { + validation::normalize_username(candidate, settings.accounts.max_username_len) + .map_err(|_invalid| UsernameChangeError::Invalid) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::auth; + + async fn fixture() -> (tempfile::TempDir, SqlitePool, Settings, i64, i64) { + let temp = tempfile::tempdir().expect("temp dir"); + let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("connect"); + crate::db::migrate(&pool).await.expect("migrate"); + let settings = Settings::default(); + let admin = + auth::register_user(&pool, &settings, "admin_user", "very secure password", true) + .await + .expect("admin"); + let alice = auth::register_user(&pool, &settings, "alice", "very secure password", false) + .await + .expect("alice"); + (temp, pool, settings, admin, alice) + } + + async fn must_change_password(pool: &SqlitePool, user_id: i64) -> bool { + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT must_change_password FROM users WHERE id = ?", + [user_id], + |row| row.get::<_, i64>(0), + )? != 0) + }) + .await + .expect("must_change_password") + } + + async fn audit_count(pool: &SqlitePool, action: &str, target: &str) -> i64 { + let action = action.to_owned(); + let target = target.to_owned(); + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM admin_audit_log WHERE action = ? AND target = ?", + params![action, target], + |row| row.get(0), + )?) + }) + .await + .expect("audit count") + } + + async fn username(pool: &SqlitePool, user_id: i64) -> Option<(String, String)> { + pool.call(move |conn| { + Ok(conn + .query_row( + "SELECT username, normalized_username FROM users WHERE id = ?", + [user_id], + |row| Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?) + }) + .await + .expect("username lookup") + } + + fn session_headers(token: &str) -> axum::http::HeaderMap { + let mut headers = axum::http::HeaderMap::new(); + headers.insert( + axum::http::header::COOKIE, + format!("rustpost_session={token}") + .parse() + .expect("session cookie"), + ); + headers + } + + /// Current plus historical rows for one canonical handle. + async fn handle_rows(pool: &SqlitePool, normalized: &str) -> i64 { + let normalized = normalized.to_owned(); + pool.call(move |conn| { + Ok(conn.query_row( + "SELECT (SELECT COUNT(*) FROM users WHERE normalized_username = ?1) + (SELECT COUNT(*) FROM username_history WHERE normalized_username = ?1)", + [&normalized], + |row| row.get(0), + )?) + }) + .await + .expect("handle rows") + } + + /// Any canonical handle owned by a current account and also reserved in history. + async fn cross_table_conflicts(pool: &SqlitePool) -> i64 { + pool.call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM users u WHERE EXISTS (SELECT 1 FROM username_history h WHERE h.normalized_username = u.normalized_username)", + (), + |row| row.get(0), + )?) + }) + .await + .expect("cross table conflicts") + } + + async fn duplicate_history_rows(pool: &SqlitePool) -> i64 { + pool.call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM (SELECT normalized_username FROM username_history GROUP BY normalized_username HAVING COUNT(*) > 1)", + (), + |row| row.get(0), + )?) + }) + .await + .expect("duplicate history rows") + } + + #[tokio::test] + async fn require_password_reset_sets_flag_and_audits() { + let (_temp, pool, _settings, admin, alice) = fixture().await; + + require_password_reset(&pool, admin, alice) + .await + .expect("require reset"); + + assert!(must_change_password(&pool, alice).await); + assert_eq!( + audit_count(&pool, "require_password_reset", &format!("user:{alice}")).await, + 1 + ); + assert_eq!( + audit_count(&pool, "require_password_reset", "user:9999").await, + 0 + ); + + // Repeating the admin action is idempotent for the flag but records + // one audit row per action. + require_password_reset(&pool, admin, alice) + .await + .expect("repeat reset"); + assert!(must_change_password(&pool, alice).await); + assert_eq!( + audit_count(&pool, "require_password_reset", &format!("user:{alice}")).await, + 2 + ); + } + + #[tokio::test] + async fn require_password_reset_restricts_existing_and_new_sessions() { + let (_temp, pool, settings, admin, alice) = fixture().await; + let before = auth::create_session(&pool, alice).await.expect("session"); + + require_password_reset(&pool, admin, alice) + .await + .expect("require reset"); + + let before_user = auth::current_user(&pool, &session_headers(&before.token)) + .await + .expect("current user") + .expect("session created before the flag stays valid"); + assert!(before_user.must_change_password); + + let after = auth::create_session(&pool, alice).await.expect("session"); + let after_user = auth::current_user(&pool, &session_headers(&after.token)) + .await + .expect("current user") + .expect("session created after the flag is restricted too"); + assert!(after_user.must_change_password); + + // The forced-reset flow clears the restriction and revokes the other + // session in one change. + auth::change_password( + &pool, + &settings, + alice, + "very secure password", + "much better password", + "much better password", + Some(&after.token), + ) + .await + .expect("change password"); + + let after_user = auth::current_user(&pool, &session_headers(&after.token)) + .await + .expect("current user") + .expect("session used for the change"); + assert!(!after_user.must_change_password); + assert!( + auth::current_user(&pool, &session_headers(&before.token)) + .await + .expect("current user") + .is_none(), + "the earlier session must be revoked by the forced reset" + ); + } + + #[tokio::test] + async fn require_password_reset_rejects_missing_and_deleted_accounts() { + let (_temp, pool, _settings, admin, alice) = fixture().await; + + let missing = require_password_reset(&pool, admin, 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 = require_password_reset(&pool, admin, alice).await; + assert_eq!( + deleted.expect_err("deleted account").to_string(), + "account not found" + ); + assert!(!must_change_password(&pool, alice).await); + assert_eq!( + audit_count(&pool, "require_password_reset", &format!("user:{alice}")).await, + 0 + ); + } + + #[tokio::test] + async fn revoke_user_sessions_revokes_and_is_idempotent() { + let (_temp, pool, settings, admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + let _alice_one = auth::create_session(&pool, alice).await.expect("alice one"); + let _alice_two = auth::create_session(&pool, alice).await.expect("alice two"); + let _bob_one = auth::create_session(&pool, bob).await.expect("bob one"); + + assert_eq!( + revoke_user_sessions(&pool, admin, alice) + .await + .expect("revoke"), + 2 + ); + assert_eq!( + revoke_user_sessions(&pool, admin, alice) + .await + .expect("revoke again"), + 0 + ); + + let (alice_active, bob_active): (i64, i64) = pool + .call(move |conn| { + Ok(( + conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [alice], + |row| row.get(0), + )?, + conn.query_row( + "SELECT COUNT(*) FROM sessions WHERE user_id = ? AND revoked_at IS NULL", + [bob], + |row| row.get(0), + )?, + )) + }) + .await + .expect("active sessions"); + assert_eq!(alice_active, 0); + assert_eq!(bob_active, 1); + assert_eq!( + audit_count(&pool, "revoke_user_sessions", &format!("user:{alice}")).await, + 2 + ); + } + + #[tokio::test] + async fn revoke_user_sessions_rejects_missing_and_deleted_accounts() { + let (_temp, pool, _settings, admin, alice) = fixture().await; + + let error = revoke_user_sessions(&pool, admin, 9999) + .await + .expect_err("missing account"); + + assert_eq!(error.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 error = revoke_user_sessions(&pool, admin, alice) + .await + .expect_err("deleted account"); + + assert_eq!(error.to_string(), "account not found"); + assert_eq!( + audit_count(&pool, "revoke_user_sessions", &format!("user:{alice}")).await, + 0 + ); + } + + #[tokio::test] + async fn change_username_records_history_and_normalizes() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let change = change_username(&pool, &settings, alice, " NewName ") + .await + .expect("change username"); + + assert_eq!( + change, + UsernameChange { + previous_username: "alice".to_owned(), + username: "NewName".to_owned(), + } + ); + let (display, normalized) = username(&pool, alice).await.expect("account"); + assert_eq!(display, "NewName"); + assert_eq!(normalized, "newname"); + let history = username_history(&pool, alice).await.expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].username, "alice"); + assert!(!history[0].changed_at.is_empty()); + } + + #[tokio::test] + async fn change_username_rejects_currently_taken_handle() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + let error = change_username(&pool, &settings, alice, "Bob") + .await + .expect_err("taken"); + + assert_eq!(error, UsernameChangeError::Taken); + assert_eq!(username(&pool, alice).await.expect("alice").0, "alice"); + assert_eq!(username(&pool, bob).await.expect("bob").0, "bob"); + } + + #[tokio::test] + async fn change_username_rejects_another_accounts_history() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename alice"); + + let error = change_username(&pool, &settings, bob, "alice") + .await + .expect_err("historical handle"); + + assert_eq!(error, UsernameChangeError::Taken); + } + + #[tokio::test] + async fn change_username_allows_same_owner_reclamation() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename away"); + + let change = change_username(&pool, &settings, alice, "alice") + .await + .expect("reclaim"); + + assert_eq!(change.previous_username, "alice_new"); + assert_eq!(change.username, "alice"); + let history = username_history(&pool, alice).await.expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].username, "alice_new"); + assert_eq!( + historical_username_holders(&pool, "alice") + .await + .expect("holders") + .len(), + 0 + ); + // Reclaiming replaces the stale row for the reclaimed handle, so it + // stays reserved for the owner but never also appears in history. + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "alice_new").await, 1); + assert_eq!( + change_username(&pool, &settings, bob, "alIce") + .await + .expect_err("reserved"), + UsernameChangeError::Taken + ); + } + + #[tokio::test] + async fn change_username_rejects_invalid_handles() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let too_long = "a".repeat(settings.accounts.max_username_len + 1); + + for candidate in ["settings", "bad name", "bad!chars", "", " ", &too_long] { + let error = change_username(&pool, &settings, alice, candidate) + .await + .expect_err("invalid handle"); + assert_eq!( + error, + UsernameChangeError::Invalid, + "candidate {candidate:?} should be invalid" + ); + } + assert_eq!(username(&pool, alice).await.expect("account").0, "alice"); + } + + #[tokio::test] + async fn change_username_accumulates_history_newest_first() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + change_username(&pool, &settings, alice, "handle_one") + .await + .expect("first change"); + change_username(&pool, &settings, alice, "handle_two") + .await + .expect("second change"); + change_username(&pool, &settings, alice, "handle_three") + .await + .expect("third change"); + + let history = username_history(&pool, alice).await.expect("history"); + let names = history + .iter() + .map(|entry| entry.username.as_str()) + .collect::>(); + assert_eq!(names, ["handle_two", "handle_one", "alice"]); + } + + #[tokio::test] + async fn change_username_unchanged_keeps_display_form() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let error = change_username(&pool, &settings, alice, "ALICE") + .await + .expect_err("unchanged"); + + assert_eq!(error, UsernameChangeError::Unchanged); + assert_eq!(username(&pool, alice).await.expect("account").0, "alice"); + assert_eq!( + username_history(&pool, alice).await.expect("history").len(), + 0 + ); + } + + #[tokio::test] + async fn change_username_rejects_missing_and_deleted_accounts() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let missing = change_username(&pool, &settings, 9999, "alice_new") + .await + .expect_err("missing"); + assert_eq!(missing, UsernameChangeError::NotFound); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + let deleted = change_username(&pool, &settings, alice, "alice_new") + .await + .expect_err("deleted"); + assert_eq!(deleted, UsernameChangeError::NotFound); + } + + #[tokio::test] + async fn change_username_concurrent_claims_leave_exactly_one_winner() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + let alice_result = change_username(&pool, &settings, alice, "shared_handle"); + let bob_result = change_username(&pool, &settings, bob, "shared_handle"); + let (alice_result, bob_result) = tokio::join!(alice_result, bob_result); + + let winners = usize::from(alice_result.is_ok()) + usize::from(bob_result.is_ok()); + assert_eq!(winners, 1, "exactly one concurrent claim should win"); + let loser = alice_result + .err() + .or_else(|| bob_result.err()) + .expect("one concurrent claim should fail"); + assert_eq!(loser, UsernameChangeError::Taken); + } + + #[tokio::test] + async fn historical_holders_exclude_deleted_accounts() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + change_username(&pool, &settings, alice, "alice_new") + .await + .expect("rename"); + + let holders = historical_username_holders(&pool, "alice") + .await + .expect("holders"); + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].user_id, alice); + assert_eq!(holders[0].username, "alice_new"); + assert_eq!(holders[0].display_name, "alice"); + assert!(!holders[0].changed_at.is_empty()); + assert!( + historical_username_holders(&pool, "never_used") + .await + .expect("empty") + .is_empty() + ); + + pool.call(move |conn| { + conn.execute("UPDATE users SET is_deleted = 1 WHERE id = ?", [alice])?; + Ok(()) + }) + .await + .expect("mark deleted"); + assert!( + historical_username_holders(&pool, "alice") + .await + .expect("deleted") + .is_empty() + ); + } + + #[tokio::test] + async fn username_history_reserves_case_variants_of_old_handle() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + change_username(&pool, &settings, alice, "MixedCase") + .await + .expect("rename alice"); + + assert_eq!(username(&pool, alice).await.expect("alice").0, "MixedCase"); + for variant in [ + "mixedcase", + "MIXEDCASE", + "MixedCase", + "MiXeDcAsE", + "alice", + "ALICE", + "aLiCe", + ] { + assert_eq!( + change_username(&pool, &settings, bob, variant) + .await + .expect_err("historical case variant"), + UsernameChangeError::Taken, + "variant {variant:?} must stay reserved" + ); + let registration = + auth::register_user(&pool, &settings, variant, "very secure password", false) + .await + .expect_err("historical case variant registration"); + assert_eq!(registration.to_string(), auth::USERNAME_TAKEN_MESSAGE); + } // Same owner and same canonical handle: Unchanged, display case kept, + // and no extra history row appears. + assert_eq!( + change_username(&pool, &settings, alice, "MIXEDCASE") + .await + .expect_err("unchanged"), + UsernameChangeError::Unchanged + ); + assert_eq!(username(&pool, alice).await.expect("alice").0, "MixedCase"); + let history = username_history(&pool, alice).await.expect("history"); + let names = history + .iter() + .map(|entry| entry.username.as_str()) + .collect::>(); + assert_eq!(names, ["alice"]); + } + + #[tokio::test] + async fn repeated_reclaims_keep_exact_history_and_reserve_old_handles() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + change_username(&pool, &settings, alice, "handle_b") + .await + .expect("first rename"); + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "handle_b").await, 1); + + change_username(&pool, &settings, alice, "alice") + .await + .expect("reclaim alice"); + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "handle_b").await, 1); + assert_eq!( + username_history(&pool, alice).await.expect("history").len(), + 1, + "reclaiming a handle must replace its stale history row" + ); + + change_username(&pool, &settings, alice, "handle_c") + .await + .expect("second rename"); + assert_eq!(handle_rows(&pool, "alice").await, 1); + assert_eq!(handle_rows(&pool, "handle_b").await, 1); + assert_eq!(handle_rows(&pool, "handle_c").await, 1); + + let history = username_history(&pool, alice).await.expect("history"); + let names = history + .iter() + .map(|entry| entry.username.as_str()) + .collect::>(); + assert_eq!( + names, + ["alice", "handle_b"], + "history must be newest-first with no stale rows" + ); + assert_eq!(duplicate_history_rows(&pool).await, 0); + assert_eq!(cross_table_conflicts(&pool).await, 0); + + for reserved in ["alice", "ALICE", "handle_b", "HANDLE_B"] { + assert_eq!( + change_username(&pool, &settings, bob, reserved) + .await + .expect_err("reserved handle"), + UsernameChangeError::Taken, + "{reserved} must stay reserved" + ); + } + // Both vacated handles still resolve to their former owner. + for former in ["alice", "handle_b"] { + let holders = historical_username_holders(&pool, former) + .await + .expect("holders"); + assert_eq!(holders.len(), 1); + assert_eq!(holders[0].user_id, alice); + assert_eq!(holders[0].username, "handle_c"); + } + } + + #[tokio::test] + async fn concurrent_case_variant_claims_leave_exactly_one_winner() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + let bob = auth::register_user(&pool, &settings, "bob", "very secure password", false) + .await + .expect("bob"); + + let alice_result = change_username(&pool, &settings, alice, "Shared"); + let bob_result = change_username(&pool, &settings, bob, "shared"); + let (alice_result, bob_result) = tokio::join!(alice_result, bob_result); + + let winners = usize::from(alice_result.is_ok()) + usize::from(bob_result.is_ok()); + assert_eq!(winners, 1, "exactly one case variant may win"); + let loser = alice_result + .err() + .or_else(|| bob_result.err()) + .expect("one claim must fail"); + assert_eq!(loser, UsernameChangeError::Taken); + assert_eq!(handle_rows(&pool, "shared").await, 1); + assert_eq!(cross_table_conflicts(&pool).await, 0); + } + + #[tokio::test] + async fn registration_racing_rename_claims_handle_exactly_once() { + let (_temp, pool, settings, _admin, alice) = fixture().await; + + let registration = auth::register_user( + &pool, + &settings, + "FreshHandle", + "very secure password", + false, + ); + let rename = change_username(&pool, &settings, alice, "freshhandle"); + let (registration, rename) = tokio::join!(registration, rename); + + let registration_won = registration.is_ok(); + let rename_won = rename.is_ok(); + assert!( + registration_won ^ rename_won, + "exactly one racer must win: registration={registration:?} rename={rename:?}" + ); + if let Err(error) = ®istration { + assert_eq!(error.to_string(), auth::USERNAME_TAKEN_MESSAGE); + } + if let Err(error) = &rename { + assert_eq!(*error, UsernameChangeError::Taken); + } + assert_eq!(handle_rows(&pool, "freshhandle").await, 1); + assert_eq!(cross_table_conflicts(&pool).await, 0); + + if registration_won { + let winner = registration.expect("registration winner"); + let (display, normalized) = username(&pool, winner).await.expect("winner"); + assert_eq!(display, "FreshHandle"); + assert_eq!(normalized, "freshhandle"); + assert_eq!(username(&pool, alice).await.expect("alice").1, "alice"); + } else { + let (display, normalized) = username(&pool, alice).await.expect("alice"); + assert_eq!(display, "freshhandle"); + assert_eq!(normalized, "freshhandle"); + let history = username_history(&pool, alice).await.expect("history"); + assert_eq!(history.len(), 1); + assert_eq!(history[0].username, "alice"); + } + } +} diff --git a/src/instance.rs b/src/instance.rs new file mode 100644 index 0000000..1d8a600 --- /dev/null +++ b/src/instance.rs @@ -0,0 +1,515 @@ +//! Instance-wide settings that administrators can change while the server is +//! running: the top-bar announcement and maintenance mode. +//! +//! Values live in the `instance_settings` key/value table so they survive +//! restarts, are covered by database backups, and do not depend on rewriting +//! `settings.toml` from the web UI. + +use rusqlite::{Connection, OptionalExtension as _}; + +use crate::db::SqlitePool; + +/// Maximum announcement length in Unicode characters. +pub const ANNOUNCEMENT_MAX_CHARS: usize = 280; +/// Maximum maintenance message length in Unicode characters. +pub const MAINTENANCE_MESSAGE_MAX_CHARS: usize = 280; +/// Prefix for released-username tombstones in `instance_settings`. +/// +/// When an account is permanently deleted its handles become claimable again. +/// A tombstone records that the handle was previously held by an account that +/// no longer exists, so an old profile URL can never silently appear to +/// represent the deleted account when someone new registers the same name. +pub const RELEASED_USERNAME_KEY_PREFIX: &str = "released_username:"; +/// Maximum tombstones recorded for one account deletion, bounding storage. +pub const MAX_RELEASED_USERNAMES_PER_ACCOUNT: usize = 50; +/// Message shown while maintenance mode is enabled without a custom message. +pub const DEFAULT_MAINTENANCE_MESSAGE: &str = + "RustPost is in maintenance mode. Posting and registration are temporarily disabled."; + +/// Durable instance state rendered in every page layout and enforced by the +/// maintenance and announcement guards. +#[derive(Debug, Clone, PartialEq, Eq, Default)] +pub struct InstanceSettings { + pub announcement: String, + pub announcement_enabled: bool, + pub maintenance_mode: bool, + pub maintenance_message: String, +} + +impl InstanceSettings { + /// Announcement text to render, or `None` when disabled or empty. + #[must_use] + pub fn announcement_text(&self) -> Option<&str> { + if !self.announcement_enabled { + return None; + } + let trimmed = self.announcement.trim(); + if trimmed.is_empty() { + return None; + } + Some(trimmed) + } + + /// Maintenance notice to render, or `None` when maintenance is off. + #[must_use] + pub fn maintenance_notice(&self) -> Option<&str> { + if !self.maintenance_mode { + return None; + } + let trimmed = self.maintenance_message.trim(); + if trimmed.is_empty() { + return Some(DEFAULT_MAINTENANCE_MESSAGE); + } + Some(trimmed) + } +} + +/// Reads the instance settings from an already-open connection. +/// +/// Missing keys fall back to the [`InstanceSettings::default`] values. Unknown +/// keys are ignored. Stored booleans are `"1"`/`"0"`, and any other value is +/// treated as `false`: a corrupt flag must never silently enable maintenance +/// mode or an announcement. +pub fn load_from_connection(conn: &Connection) -> anyhow::Result { + let mut settings = InstanceSettings::default(); + let mut stmt = conn.prepare("SELECT key, value FROM instance_settings")?; + let rows = stmt.query_map([], |row| { + Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?)) + })?; + for row in rows { + let (key, value) = row?; + match key.as_str() { + "announcement" => settings.announcement = value, + "announcement_enabled" => settings.announcement_enabled = truthy(&value), + "maintenance_mode" => settings.maintenance_mode = truthy(&value), + "maintenance_message" => settings.maintenance_message = value, + _ => {} + } + } + Ok(settings) +} + +/// Interprets a stored boolean. Only the canonical `"1"` written by the save +/// helpers counts as `true`; anything else is `false`. +fn truthy(value: &str) -> bool { + value == "1" +} + +/// Reads the instance settings through the database worker. +pub async fn load(pool: &SqlitePool) -> anyhow::Result { + pool.call(|conn| load_from_connection(conn)).await +} + +/// Stores the announcement text and enabled flag. +/// +/// Rejects text longer than [`ANNOUNCEMENT_MAX_CHARS`] and control characters +/// other than newlines and tabs. An empty announcement is stored as disabled. +pub async fn save_announcement(pool: &SqlitePool, text: &str, enabled: bool) -> anyhow::Result<()> { + validate_setting_text( + text, + ANNOUNCEMENT_MAX_CHARS, + "announcement is too long", + "announcement contains unsupported control characters", + )?; + let text = text.to_owned(); + let enabled = enabled && !text.trim().is_empty(); + pool.call(move |conn| { + let tx = conn.transaction()?; + upsert_setting_tx(&tx, "announcement", &text)?; + upsert_setting_tx(&tx, "announcement_enabled", if enabled { "1" } else { "0" })?; + tx.commit()?; + Ok(()) + }) + .await +} + +/// Stores the maintenance flag and optional message. +/// +/// Rejects messages longer than [`MAINTENANCE_MESSAGE_MAX_CHARS`] and control +/// characters other than newlines and tabs. +pub async fn save_maintenance( + pool: &SqlitePool, + enabled: bool, + message: &str, +) -> anyhow::Result<()> { + validate_setting_text( + message, + MAINTENANCE_MESSAGE_MAX_CHARS, + "maintenance message is too long", + "maintenance message contains unsupported control characters", + )?; + let message = message.to_owned(); + pool.call(move |conn| { + let tx = conn.transaction()?; + upsert_setting_tx(&tx, "maintenance_mode", if enabled { "1" } else { "0" })?; + upsert_setting_tx(&tx, "maintenance_message", &message)?; + tx.commit()?; + Ok(()) + }) + .await +} + +fn validate_setting_text( + text: &str, + max_chars: usize, + too_long_error: &str, + control_chars_error: &str, +) -> anyhow::Result<()> { + if text.chars().count() > max_chars { + anyhow::bail!("{too_long_error}"); + } + if text + .chars() + .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t')) + { + anyhow::bail!("{control_chars_error}"); + } + Ok(()) +} + +fn upsert_setting_tx(tx: &rusqlite::Transaction<'_>, key: &str, value: &str) -> anyhow::Result<()> { + tx.execute( + r#" + INSERT INTO instance_settings (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value, updated_at = CURRENT_TIMESTAMP + "#, + rusqlite::params![key, value], + )?; + Ok(()) +} + +/// Records release tombstones for handles freed by an account deletion. +/// +/// Must run inside the deletion transaction. Only normalized handles are +/// stored; no user identity is retained. Handles are ignored when empty, +/// control-containing, or longer than a username, and at most +/// [`MAX_RELEASED_USERNAMES_PER_ACCOUNT`] handles are recorded. +pub fn record_released_usernames_tx( + tx: &rusqlite::Transaction<'_>, + normalized_usernames: &[String], +) -> anyhow::Result<()> { + let count = normalized_usernames + .len() + .min(MAX_RELEASED_USERNAMES_PER_ACCOUNT); + for normalized in normalized_usernames.iter().take(count) { + if normalized.is_empty() + || normalized.chars().count() > 128 + || normalized.chars().any(char::is_control) + { + continue; + } + upsert_setting_tx( + tx, + &released_username_key(normalized), + RELEASED_USERNAME_TOMBSTONE_VALUE, + )?; + } + Ok(()) +} + +/// Records one release tombstone on its own. Used by tests and by callers +/// outside a deletion transaction; the deletion path uses +/// [`record_released_usernames_tx`] inside its transaction. +pub async fn record_released_username( + pool: &SqlitePool, + normalized_username: &str, +) -> anyhow::Result<()> { + let normalized = normalized_username.to_owned(); + pool.call(move |conn| { + let tx = conn.transaction()?; + record_released_usernames_tx(&tx, std::slice::from_ref(&normalized))?; + tx.commit()?; + Ok(()) + }) + .await +} + +/// Whether a handle was released by a permanently deleted account. +pub async fn username_was_released( + pool: &SqlitePool, + normalized_username: &str, +) -> anyhow::Result { + let key = released_username_key(normalized_username); + pool.call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM instance_settings WHERE key = ?", + [key], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await +} + +fn released_username_key(normalized_username: &str) -> String { + format!("{RELEASED_USERNAME_KEY_PREFIX}{normalized_username}") +} + +/// Stored value for a release tombstone. The timestamp lives in `updated_at`. +const RELEASED_USERNAME_TOMBSTONE_VALUE: &str = "1"; + +#[cfg(test)] +mod tests { + use super::*; + + async fn fixture() -> (tempfile::TempDir, SqlitePool) { + let temp = tempfile::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) + } + + #[tokio::test] + async fn empty_instance_settings_use_defaults() { + let (_temp, pool) = fixture().await; + + let settings = load(&pool).await.expect("load"); + + assert_eq!(settings, InstanceSettings::default()); + assert_eq!(settings.announcement_text(), None); + assert_eq!(settings.maintenance_notice(), None); + } + + #[tokio::test] + async fn saved_settings_survive_a_fresh_connection() { + let (temp, pool) = fixture().await; + save_announcement(&pool, " Hello\nworld ", true) + .await + .expect("announcement"); + save_maintenance(&pool, true, " back at 20:00 ") + .await + .expect("maintenance"); + + let reloaded = load(&pool).await.expect("reload"); + assert_eq!(reloaded.announcement, " Hello\nworld "); + assert!(reloaded.announcement_enabled); + assert_eq!(reloaded.announcement_text(), Some("Hello\nworld")); + assert!(reloaded.maintenance_mode); + assert_eq!(reloaded.maintenance_message, " back at 20:00 "); + assert_eq!(reloaded.maintenance_notice(), Some("back at 20:00")); + + let reopened = crate::db::connect(&temp.path().join("test.sqlite3")) + .await + .expect("reopen"); + let persisted = load(&reopened).await.expect("persisted"); + assert_eq!(persisted, reloaded); + } + + #[tokio::test] + async fn saves_refresh_updated_at() { + let (_temp, pool) = fixture().await; + save_announcement(&pool, "first", true) + .await + .expect("announcement"); + pool.call(|conn| { + conn.execute( + "UPDATE instance_settings SET updated_at = '2000-01-01 00:00:00'", + [], + )?; + Ok(()) + }) + .await + .expect("age rows"); + + save_announcement(&pool, "second", true) + .await + .expect("second announcement"); + + let updated_at: String = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT updated_at FROM instance_settings WHERE key = 'announcement'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("updated_at"); + assert_ne!(updated_at, "2000-01-01 00:00:00"); + } + + #[tokio::test] + async fn enabled_empty_announcement_renders_nothing() { + let (_temp, pool) = fixture().await; + + save_announcement(&pool, " ", true) + .await + .expect("empty announcement"); + + let settings = load(&pool).await.expect("load"); + assert!(!settings.announcement_enabled); + assert_eq!(settings.announcement_text(), None); + + save_announcement(&pool, "visible text", false) + .await + .expect("disabled announcement"); + let settings = load(&pool).await.expect("load"); + assert!(!settings.announcement_enabled); + assert_eq!(settings.announcement_text(), None); + } + + #[tokio::test] + async fn rejects_long_and_control_character_settings() { + let (_temp, pool) = fixture().await; + + let max_length = "é".repeat(ANNOUNCEMENT_MAX_CHARS); + save_announcement(&pool, &max_length, true) + .await + .expect("max-length announcement"); + let too_long = save_announcement(&pool, &"x".repeat(ANNOUNCEMENT_MAX_CHARS + 1), true) + .await + .expect_err("too-long announcement"); + assert_eq!(too_long.to_string(), "announcement is too long"); + let control = save_announcement(&pool, "bad\u{7}text", true) + .await + .expect_err("control characters"); + assert_eq!( + control.to_string(), + "announcement contains unsupported control characters" + ); + save_announcement(&pool, "line\nbreak\ttab", true) + .await + .expect("allowed whitespace"); + + let too_long = + save_maintenance(&pool, true, &"m".repeat(MAINTENANCE_MESSAGE_MAX_CHARS + 1)) + .await + .expect_err("too-long maintenance message"); + assert_eq!(too_long.to_string(), "maintenance message is too long"); + let control = save_maintenance(&pool, true, "bad\u{0}text") + .await + .expect_err("control characters"); + assert_eq!( + control.to_string(), + "maintenance message contains unsupported control characters" + ); + } + + #[tokio::test] + async fn empty_maintenance_message_uses_the_default_notice() { + let (_temp, pool) = fixture().await; + + save_maintenance(&pool, true, " ") + .await + .expect("maintenance on"); + let settings = load(&pool).await.expect("load"); + assert!(settings.maintenance_mode); + assert_eq!( + settings.maintenance_notice(), + Some(DEFAULT_MAINTENANCE_MESSAGE) + ); + + save_maintenance(&pool, false, "") + .await + .expect("maintenance off"); + let settings = load(&pool).await.expect("load"); + assert!(!settings.maintenance_mode); + assert_eq!(settings.maintenance_notice(), None); + } + + #[tokio::test] + async fn corrupt_booleans_and_unknown_keys_default_off() { + let (_temp, pool) = fixture().await; + pool.call(|conn| { + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('maintenance_mode', 'maybe')", + [], + )?; + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('announcement_enabled', 'yes')", + [], + )?; + conn.execute( + "INSERT INTO instance_settings (key, value) VALUES ('unknown_future_key', 'x')", + [], + )?; + Ok(()) + }) + .await + .expect("corrupt rows"); + + let settings = load(&pool).await.expect("load"); + + assert!(!settings.maintenance_mode); + assert!(!settings.announcement_enabled); + assert_eq!(settings, InstanceSettings::default()); + } + + #[tokio::test] + async fn released_usernames_are_recorded_and_queryable() { + let (_temp, pool) = fixture().await; + + assert!(!username_was_released(&pool, "alice").await.expect("query")); + record_released_username(&pool, "alice") + .await + .expect("record"); + assert!(username_was_released(&pool, "alice").await.expect("query")); + assert!(!username_was_released(&pool, "bob").await.expect("query")); + + // Recording again refreshes the timestamp without duplicating a key. + record_released_username(&pool, "alice") + .await + .expect("record"); + let rows: i64 = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM instance_settings WHERE key = 'released_username:alice'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("count"); + assert_eq!(rows, 1); + + // Tombstones must not leak into the announcement/maintenance view. + assert_eq!( + load(&pool).await.expect("load"), + InstanceSettings::default() + ); + } + + #[tokio::test] + async fn released_usernames_are_bounded_and_screened() { + let (_temp, pool) = fixture().await; + let mut handles = (0..MAX_RELEASED_USERNAMES_PER_ACCOUNT + 25) + .map(|index| format!("handle{index}")) + .collect::>(); + handles.push(String::new()); + handles.push("bad\u{7}name".to_owned()); + + pool.call(move |conn| { + let tx = conn.transaction()?; + record_released_usernames_tx(&tx, &handles)?; + tx.commit()?; + Ok(()) + }) + .await + .expect("record bounded"); + + let count: i64 = pool + .call(|conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM instance_settings WHERE key LIKE 'released_username:%'", + [], + |row| row.get(0), + )?) + }) + .await + .expect("count"); + assert_eq!( + count, + i64::try_from(MAX_RELEASED_USERNAMES_PER_ACCOUNT).expect("limit fits") + ); + assert!(!username_was_released(&pool, "").await.expect("empty")); + assert!( + !username_was_released(&pool, "bad\u{7}name") + .await + .expect("control") + ); + } +} diff --git a/src/lib.rs b/src/lib.rs index 30c1567..cae6a45 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -28,8 +28,11 @@ pub mod demo_seed; pub mod errors; pub mod favicon; pub mod ffmpeg; +pub mod identity; +pub mod instance; pub mod logging; pub mod media; +pub mod portability; pub mod rate_limit; pub mod registration_captcha; pub mod render; diff --git a/src/media.rs b/src/media.rs index 91c53ca..6396cef 100644 --- a/src/media.rs +++ b/src/media.rs @@ -42,6 +42,7 @@ struct StoredMedia { media_kind: String, original_sha256: String, normalized_sha256: Option, + is_nsfw: bool, } #[derive(Debug, Clone)] @@ -69,20 +70,62 @@ struct UploadContext<'a> { struct StagedUpload { owner_user_id: Option, original_filename: String, - staging: PathBuf, + staging: StagedUploadGuard, bytes: u64, } struct PreparedUpload { owner_user_id: Option, original_filename: String, - staging: PathBuf, + staging: StagedUploadGuard, bytes: u64, mime: String, media_kind: MediaKind, original_sha256: String, } +struct StagedUploadGuard { + path: PathBuf, + armed: bool, +} + +impl StagedUploadGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn path(&self) -> &Path { + &self.path + } + + fn disarm(&mut self) { + self.armed = false; + } + + async fn remove(&mut self) { + if remove_staged_upload(&self.path).await { + self.disarm(); + } + } +} + +impl Drop for StagedUploadGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Err(error) = std::fs::remove_file(&self.path) + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::debug!( + path = %self.path.display(), + error = %error, + "failed to remove staged upload during guard drop" + ); + } + } +} + struct NewMediaRecord { owner_user_id: Option, original_filename: String, @@ -140,8 +183,8 @@ async fn save_upload_inner( let original_filename = field.file_name().unwrap_or("upload").to_owned(); reject_path_tricks(&original_filename)?; let id = Uuid::new_v4().simple().to_string(); - let staging = paths.staged_upload_path(&id); - let bytes = write_upload_to_staging(settings, &staging, &mut field).await?; + let staging = StagedUploadGuard::new(paths.staged_upload_path(&id)); + let bytes = write_upload_to_staging(settings, staging.path(), &mut field).await?; let context = UploadContext { pool, settings, @@ -166,11 +209,18 @@ async fn save_staged_upload( upload: StagedUpload, required_image_label: Option<&'static str>, ) -> anyhow::Result { - let prepared = prepare_staged_upload(context.settings, upload).await?; + save_staged_upload_inner(context, upload, required_image_label).await +} + +async fn save_staged_upload_inner( + context: &UploadContext<'_>, + upload: StagedUpload, + required_image_label: Option<&'static str>, +) -> anyhow::Result { + let mut prepared = prepare_staged_upload(context.settings, upload).await?; if let Some(label) = required_image_label && prepared.media_kind != MediaKind::Image { - remove_staged_upload(&prepared.staging).await; anyhow::bail!("{label} must be an image"); } if let Some(media_id) = try_insert_duplicate( @@ -181,7 +231,7 @@ async fn save_staged_upload( ) .await? { - remove_staged_upload(&prepared.staging).await; + prepared.staging.remove().await; return Ok(media_id); } store_new_upload(context, prepared).await @@ -191,19 +241,20 @@ async fn prepare_staged_upload( settings: &Settings, upload: StagedUpload, ) -> anyhow::Result { - let data = tokio::fs::read(&upload.staging).await?; - let Some(kind) = infer::get(&data) else { - remove_staged_upload(&upload.staging).await; + let (kind, original_sha256, prefix) = sniff_and_hash(upload.staging.path()).await?; + let Some(kind) = kind else { anyhow::bail!("unsupported media type"); }; let mime = kind.mime_type().to_owned(); - let media_kind = match classify(settings, &mime, upload.bytes) { - Ok(media_kind) => media_kind, - Err(error) => { - remove_staged_upload(&upload.staging).await; - return Err(error); + let media_kind = classify(settings, &mime, upload.bytes)?; + if media_kind == MediaKind::Image + && let Some((width, height)) = image_dimensions(&prefix) + { + let pixels = u64::from(width).saturating_mul(u64::from(height)); + if pixels > MAX_IMAGE_PIXELS { + anyhow::bail!("image dimensions exceed the maximum supported size"); } - }; + } Ok(PreparedUpload { owner_user_id: upload.owner_user_id, original_filename: upload.original_filename, @@ -211,10 +262,137 @@ async fn prepare_staged_upload( bytes: upload.bytes, mime, media_kind, - original_sha256: sha256_hex(&data), + original_sha256, }) } +/// Upper bound used to reject decompression-bomb style images before ffmpeg +/// decodes them. 100 megapixels is far above any realistic profile or post +/// image while keeping a full RGBA decode under ~400 MB. +const MAX_IMAGE_PIXELS: u64 = 100_000_000; + +/// Reads width/height from the headers of the image formats `RustPost` accepts. +/// Returns `None` for unknown or truncated headers. +fn image_dimensions(data: &[u8]) -> Option<(u32, u32)> { + const PNG_SIGNATURE: &[u8] = b"\x89PNG\r\n\x1a\n"; + if data.starts_with(PNG_SIGNATURE) { + let width = read_be_u32(data, 16)?; + let height = read_be_u32(data, 20)?; + return Some((width, height)); + } + if data.starts_with(b"GIF87a") || data.starts_with(b"GIF89a") { + let width = read_le_u16(data, 6)?; + let height = read_le_u16(data, 8)?; + return Some((u32::from(width), u32::from(height))); + } + if data.starts_with(&[0xFF, 0xD8]) { + return jpeg_dimensions(data); + } + if data.len() >= 30 && data.starts_with(b"RIFF") && &data[8..12] == b"WEBP" { + return match &data[12..16] { + b"VP8X" => { + let width = read_le_u24(data, 24)? + 1; + let height = read_le_u24(data, 27)? + 1; + Some((width, height)) + } + b"VP8 " => { + let width = u32::from(read_le_u16(data, 26)? & 0x3FFF); + let height = u32::from(read_le_u16(data, 28)? & 0x3FFF); + Some((width, height)) + } + b"VP8L" => { + let bits = read_le_u32(data, 21)?; + let width = (bits & 0x3FFF) + 1; + let height = ((bits >> 14) & 0x3FFF) + 1; + Some((width, height)) + } + _ => None, + }; + } + None +} + +fn read_be_u32(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 4)?; + Some(u32::from_be_bytes(bytes.try_into().ok()?)) +} + +fn read_le_u32(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 4)?; + Some(u32::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_le_u16(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 2)?; + Some(u16::from_le_bytes(bytes.try_into().ok()?)) +} + +fn read_le_u24(data: &[u8], offset: usize) -> Option { + let bytes = data.get(offset..offset + 3)?; + Some(u32::from(bytes[0]) | (u32::from(bytes[1]) << 8) | (u32::from(bytes[2]) << 16)) +} + +fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> { + let mut index = 2usize; + while index + 9 < data.len() { + if data[index] != 0xFF { + index += 1; + continue; + } + let marker = data[index + 1]; + if matches!(marker, 0xD8 | 0xD9 | 0x01) || (0xD0..=0xD7).contains(&marker) { + index += 2; + continue; + } + let length = usize::from(u16::from_be_bytes([data[index + 2], data[index + 3]])); + if length < 2 { + return None; + } + // Start-of-frame markers (excluding DHT/JPG/DAC at C4/C8/CC). + let is_sof = matches!(marker, 0xC0..=0xC3 | 0xC5..=0xC7 | 0xC9..=0xCB | 0xCD..=0xCF); + if is_sof { + let height = u32::from(u16::from_be_bytes([data[index + 5], data[index + 6]])); + let width = u32::from(u16::from_be_bytes([data[index + 7], data[index + 8]])); + return Some((width, height)); + } + index += 2 + length; + } + None +} + +/// Number of leading bytes inspected for content-based media type detection. +/// +/// The `infer` signatures are all at the start of a file, so a bounded prefix +/// is enough and avoids loading an entire upload (potentially hundreds of +/// megabytes of video) into memory. +const MEDIA_SNIFF_PREFIX_BYTES: usize = 64 * 1024; + +/// Streams a staged upload once, sniffing its magic bytes and hashing it +/// without buffering the whole file. The bounded prefix is also reused for +/// image dimension checks. +async fn sniff_and_hash(path: &Path) -> anyhow::Result<(Option, String, Vec)> { + use tokio::io::AsyncReadExt as _; + + let mut file = tokio::fs::File::open(path).await?; + let mut buffer = vec![0_u8; MEDIA_SNIFF_PREFIX_BYTES]; + let mut prefix = Vec::with_capacity(MEDIA_SNIFF_PREFIX_BYTES); + let mut hasher = Sha256::new(); + loop { + let read = file.read(&mut buffer).await?; + if read == 0 { + break; + } + if prefix.len() < MEDIA_SNIFF_PREFIX_BYTES { + let take = (MEDIA_SNIFF_PREFIX_BYTES - prefix.len()).min(read); + prefix.extend_from_slice(&buffer[..take]); + } + hasher.update(&buffer[..read]); + } + let kind = infer::get(&prefix); + let hash = hex_lower(hasher.finalize().as_ref()); + Ok((kind, hash, prefix)) +} + async fn try_insert_duplicate( context: &UploadContext<'_>, upload: &PreparedUpload, @@ -252,7 +430,7 @@ async fn try_insert_duplicate( async fn store_new_upload( context: &UploadContext<'_>, - upload: PreparedUpload, + mut upload: PreparedUpload, ) -> anyhow::Result { let ext = safe_extension(&upload.mime, upload.media_kind); let mut basename = stable_media_basename(&upload.original_filename, &upload.original_sha256); @@ -261,7 +439,8 @@ async fn store_new_upload( stem.clone_into(&mut basename); } let original_public_path = public_upload_path(context.paths, &original_path)?; - tokio::fs::rename(&upload.staging, &original_path).await?; + tokio::fs::rename(upload.staging.path(), &original_path).await?; + upload.staging.disarm(); let stored = convert_or_original( context.settings, context.paths, @@ -272,7 +451,13 @@ async fn store_new_upload( &upload.mime, ) .await; - let normalized_sha256 = hash_file(&stored.path).await?; + let normalized_sha256 = match hash_file(&stored.path).await { + Ok(hash) => hash, + Err(error) => { + cleanup_unreferenced_uploads(context.pool, [&original_path, &stored.path]).await; + return Err(error); + } + }; if stored.state == "converted" && !context.settings.media.keep_original_uploads { let _ = tokio::fs::remove_file(&original_path).await; } @@ -285,7 +470,7 @@ async fn store_new_upload( ) .await? { - cleanup_unreferenced_uploads([&original_path, &stored.path]).await; + cleanup_unreferenced_uploads(context.pool, [&original_path, &stored.path]).await; return Ok(media_id); } @@ -324,10 +509,12 @@ async fn store_new_upload( try_insert_duplicate_after_canonical_conflict(context, &upload, &normalized_sha256) .await? { - cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; + cleanup_unreferenced_uploads(context.pool, [&cleanup_paths[0], &cleanup_paths[1]]) + .await; return Ok(media_id); } - cleanup_unreferenced_uploads([&cleanup_paths[0], &cleanup_paths[1]]).await; + cleanup_unreferenced_uploads(context.pool, [&cleanup_paths[0], &cleanup_paths[1]]) + .await; return Err(error); } }; @@ -424,7 +611,7 @@ async fn insert_duplicate_media( .filter(|hash| !hash.is_empty()); pool.call(move |conn| { conn.execute( - "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, original_sha256, normalized_sha256, canonical_media_id) VALUES (?, ?, ?, ?, ?, ?, ?, 'duplicate', ?, ?, ?)", + "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len, conversion_state, original_sha256, normalized_sha256, canonical_media_id, is_nsfw) VALUES (?, ?, ?, ?, ?, ?, ?, 'duplicate', ?, ?, ?, ?)", params![ owner_user_id, original_filename, @@ -436,6 +623,7 @@ async fn insert_duplicate_media( original_sha256, normalized_sha256, canonical.id, + i64::from(canonical.is_nsfw), ], )?; Ok(conn.last_insert_rowid()) @@ -447,13 +635,24 @@ async fn write_upload_to_staging( settings: &Settings, staging: &Path, field: &mut Field<'_>, +) -> anyhow::Result { + let result = write_upload_to_staging_inner(settings, staging, field).await; + if result.is_err() { + remove_staged_upload(staging).await; + } + result +} + +async fn write_upload_to_staging_inner( + settings: &Settings, + staging: &Path, + field: &mut Field<'_>, ) -> anyhow::Result { let mut file = tokio::fs::File::create(staging).await?; let mut bytes = 0_u64; while let Some(chunk) = field.chunk().await? { bytes += u64::try_from(chunk.len())?; if bytes > settings.media.max_video_size { - remove_staged_upload(staging).await; anyhow::bail!("upload exceeds maximum size"); } file.write_all(&chunk).await?; @@ -462,6 +661,26 @@ async fn write_upload_to_staging( Ok(bytes) } +pub async fn save_banner_upload( + pool: &SqlitePool, + settings: &Settings, + paths: &RuntimePaths, + ffmpeg: &FfmpegStatus, + owner_user_id: i64, + field: Field<'_>, +) -> anyhow::Result { + save_upload_inner( + pool, + settings, + paths, + ffmpeg, + Some(owner_user_id), + field, + Some("profile banner"), + ) + .await +} + pub async fn save_profile_picture_upload( pool: &SqlitePool, settings: &Settings, @@ -550,15 +769,41 @@ async fn generate_profile_picture_thumbnail( Ok(()) } -async fn remove_staged_upload(path: &Path) { - if let Err(error) = tokio::fs::remove_file(path).await { - tracing::debug!(error = %error, "failed to remove rejected staged upload"); +async fn remove_staged_upload(path: &Path) -> bool { + match tokio::fs::remove_file(path).await { + Ok(()) => true, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => true, + Err(error) => { + tracing::debug!(error = %error, "failed to remove rejected staged upload"); + false + } } } -async fn cleanup_unreferenced_uploads(paths: [&Path; N]) { +/// Removes upload scratch files that are not referenced by any `media` row. +/// +/// The reference check protects a concurrently committed canonical row from +/// having its file removed by a duplicate-upload cleanup path. +async fn cleanup_unreferenced_uploads(pool: &SqlitePool, paths: [&Path; N]) { let unique_paths = paths.into_iter().collect::>(); for path in unique_paths { + let path_string = path.to_string_lossy().to_string(); + let referenced = pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ? LIMIT 1", + params![path_string, path_string, path_string], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await + .unwrap_or(true); + if referenced { + continue; + } if let Err(error) = tokio::fs::remove_file(path).await && error.kind() != std::io::ErrorKind::NotFound { @@ -585,10 +830,10 @@ async fn find_duplicate_media( .call(move |conn| { let sql = match hash_kind { MediaHashKind::Original => { - "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256 FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? ORDER BY id ASC LIMIT 8" + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256, is_nsfw FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? ORDER BY id ASC LIMIT 8" } MediaHashKind::Normalized => { - "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256 FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND normalized_sha256 = ? ORDER BY id ASC LIMIT 8" + "SELECT id, stored_path, public_path, mime_type, media_kind, original_sha256, normalized_sha256, is_nsfw FROM media WHERE canonical_media_id IS NULL AND media_kind = ? AND normalized_sha256 = ? ORDER BY id ASC LIMIT 8" } }; let mut stmt = conn.prepare(sql)?; @@ -602,6 +847,7 @@ async fn find_duplicate_media( media_kind: row.get(4)?, original_sha256: row.get(5)?, normalized_sha256: row.get(6)?, + is_nsfw: row.get::<_, i64>(7)? != 0, }) })? .collect::, _>>()?; @@ -755,14 +1001,36 @@ fn mime_matches_kind(mime_type: &str, media_kind: MediaKind) -> bool { } async fn hash_file(path: &Path) -> anyhow::Result { - let data = tokio::fs::read(path).await?; - Ok(sha256_hex(&data)) + use tokio::io::AsyncReadExt as _; + + let mut file = tokio::fs::File::open(path).await?; + let mut buffer = vec![0_u8; MEDIA_SNIFF_PREFIX_BYTES]; + let mut hasher = Sha256::new(); + loop { + let read = file.read(&mut buffer).await?; + if read == 0 { + break; + } + hasher.update(&buffer[..read]); + } + Ok(hex_lower(hasher.finalize().as_ref())) } +#[cfg(test)] fn sha256_hex(data: &[u8]) -> String { let mut hasher = Sha256::new(); hasher.update(data); - format!("{:x}", hasher.finalize()) + hex_lower(hasher.finalize().as_ref()) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded } fn stable_media_basename(original_filename: &str, original_sha256: &str) -> String { @@ -806,17 +1074,35 @@ fn stable_media_basename(original_filename: &str, original_sha256: &str) -> Stri format!("{}-{}", sanitized, &original_sha256[..prefix_len]) } +/// Picks an unused output path and claims it atomically. +/// +/// Concurrent uploads of identical bytes derive the same basename, so a plain +/// `exists()` check would let two conversions target the same file. Creating +/// the file with `create_new` claims the name at the filesystem level; callers +/// overwrite or rename over the claimed (empty) file. fn unique_media_path(dir: &Path, basename: &str, extension: &str) -> PathBuf { - let path = dir.join(format!("{basename}.{extension}")); - if !path.exists() { - return path; - } - dir.join(format!( + let fallback = dir.join(format!( "{}-{}.{}", basename, Uuid::new_v4().simple(), extension - )) + )); + let candidates = std::iter::once(dir.join(format!("{basename}.{extension}"))) + .chain(std::iter::once(fallback.clone())); + for candidate in candidates { + match std::fs::OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(_claim) => return candidate, + Err(error) if error.kind() == std::io::ErrorKind::AlreadyExists => {} + // The directory may be missing or read-only; return the intended + // path so the caller surfaces the real I/O error. + Err(_error) => return candidate, + } + } + fallback } fn public_upload_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result { @@ -846,6 +1132,7 @@ fn has_parent_component(path: &Path) -> bool { pub async fn set_profile_media( pool: &SqlitePool, + paths: &RuntimePaths, user_id: i64, slot: ProfileMediaSlot, media_id: i64, @@ -862,13 +1149,16 @@ pub async fn set_profile_media( }) .await?; if media_kind.as_deref() != Some("image") { - delete_media(pool, media_id).await?; + delete_media(pool, paths, media_id).await?; anyhow::bail!("profile media must be an image"); } let select_sql = format!("SELECT {} FROM users WHERE id = ?", slot.column()); let previous: Option = pool .call(move |conn| Ok(conn.query_row(&select_sql, [user_id], |row| row.get(0))?)) .await?; + if let Some(previous) = previous.filter(|previous| *previous != media_id) { + validate_media_deletion_paths(pool, paths, previous).await?; + } let update_sql = format!( "UPDATE users SET {} = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", slot.column() @@ -879,13 +1169,14 @@ pub async fn set_profile_media( }) .await?; if let Some(previous) = previous.filter(|previous| *previous != media_id) { - delete_media(pool, previous).await?; + delete_media(pool, paths, previous).await?; } Ok(()) } pub async fn clear_profile_media( pool: &SqlitePool, + paths: &RuntimePaths, user_id: i64, slot: ProfileMediaSlot, ) -> anyhow::Result<()> { @@ -893,6 +1184,9 @@ pub async fn clear_profile_media( let previous: Option = pool .call(move |conn| Ok(conn.query_row(&select_sql, [user_id], |row| row.get(0))?)) .await?; + if let Some(previous) = previous { + validate_media_deletion_paths(pool, paths, previous).await?; + } let update_sql = format!( "UPDATE users SET {} = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = ?", slot.column() @@ -903,15 +1197,35 @@ pub async fn clear_profile_media( }) .await?; if let Some(previous) = previous { - delete_media(pool, previous).await?; + delete_media(pool, paths, previous).await?; } Ok(()) } -pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<()> { +pub async fn delete_media( + pool: &SqlitePool, + paths: &RuntimePaths, + media_id: i64, +) -> anyhow::Result<()> { + validate_media_deletion_paths(pool, paths, media_id).await?; let paths_to_remove = pool .call(move |conn| { let tx = conn.transaction()?; + let referenced: bool = tx.query_row( + r#" + SELECT EXISTS( + SELECT 1 FROM post_media WHERE media_id = ? + UNION ALL + SELECT 1 FROM users + WHERE profile_picture_media_id = ? OR banner_media_id = ? + ) + "#, + params![media_id, media_id, media_id], + |row| row.get(0), + )?; + if referenced { + anyhow::bail!("media is still referenced by a post or profile"); + } let media_paths: Option<(Option, String, Option)> = tx .query_row( "SELECT original_path, stored_path, thumbnail_path FROM media WHERE id = ?", @@ -959,6 +1273,178 @@ pub async fn delete_media(pool: &SqlitePool, media_id: i64) -> anyhow::Result<() Ok(()) } +async fn validate_media_deletion_paths( + pool: &SqlitePool, + paths: &RuntimePaths, + media_id: i64, +) -> anyhow::Result<()> { + let media_paths: Option<(Option, String, Option)> = pool + .call(move |conn| { + conn.query_row( + "SELECT original_path, stored_path, thumbnail_path FROM media WHERE id = ?", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional() + .map_err(Into::into) + }) + .await?; + let Some((original_path, stored_path, thumbnail_path)) = media_paths else { + return Ok(()); + }; + for path in [original_path, Some(stored_path), thumbnail_path] + .into_iter() + .flatten() + .filter(|path| !path.is_empty()) + { + validate_media_deletion_path(paths, Path::new(&path)).await?; + } + Ok(()) +} + +async fn validate_media_deletion_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result<()> { + if !path.is_absolute() || has_parent_component(path) { + anyhow::bail!("refusing to delete unsafe media path {}", path.display()); + } + let allowed_roots = [ + &paths.uploads_originals, + &paths.uploads_images, + &paths.uploads_videos, + &paths.uploads_thumbs, + ]; + let Some(root) = allowed_roots + .into_iter() + .find(|root| path.starts_with(root)) + else { + anyhow::bail!( + "refusing to delete media path outside upload directories: {}", + path.display() + ); + }; + let metadata = match tokio::fs::symlink_metadata(path).await { + Ok(metadata) => metadata, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(()), + Err(error) => return Err(error.into()), + }; + if !metadata.file_type().is_file() { + anyhow::bail!("refusing to delete non-file media path {}", path.display()); + } + let canonical_root = tokio::fs::canonicalize(root).await?; + let canonical_path = tokio::fs::canonicalize(path).await?; + if !canonical_path.starts_with(canonical_root) { + anyhow::bail!( + "refusing to delete media path outside upload directories: {}", + path.display() + ); + } + Ok(()) +} + +pub async fn delete_post_media( + pool: &SqlitePool, + paths: &RuntimePaths, + post_id: i64, +) -> anyhow::Result<()> { + let media_ids = post_media_ids(pool, post_id).await?; + for media_id in &media_ids { + validate_media_deletion_paths(pool, paths, *media_id).await?; + } + let paths_to_remove = pool + .call(move |conn| { + let tx = conn.transaction()?; + tx.execute("DELETE FROM post_media WHERE post_id = ?", [post_id])?; + let mut paths = BTreeSet::new(); + for media_id in media_ids { + let referenced: bool = tx.query_row( + r#" + SELECT EXISTS( + SELECT 1 FROM post_media WHERE media_id = ? + UNION ALL + SELECT 1 FROM users + WHERE profile_picture_media_id = ? OR banner_media_id = ? + ) + "#, + params![media_id, media_id, media_id], + |row| row.get(0), + )?; + if referenced { + continue; + } + let media_paths: Option<(Option, String, Option)> = tx + .query_row( + "SELECT original_path, stored_path, thumbnail_path FROM media WHERE id = ?", + [media_id], + |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), + ) + .optional()?; + let Some((original_path, stored_path, thumbnail_path)) = media_paths else { + continue; + }; + promote_canonical_references(&tx, media_id)?; + tx.execute("DELETE FROM media_jobs WHERE media_id = ?", [media_id])?; + tx.execute("DELETE FROM media WHERE id = ?", [media_id])?; + for path in [original_path, Some(stored_path), thumbnail_path] + .into_iter() + .flatten() + .filter(|path| !path.is_empty()) + { + let remaining: i64 = tx.query_row( + "SELECT COUNT(*) FROM media WHERE original_path = ? OR stored_path = ? OR thumbnail_path = ?", + params![path, path, path], + |row| row.get(0), + )?; + if remaining == 0 { + paths.insert(PathBuf::from(path)); + } + } + } + tx.commit()?; + Ok(paths.into_iter().collect::>()) + }) + .await?; + remove_media_files(paths_to_remove, post_id, "post media cleanup").await; + Ok(()) +} + +pub async fn validate_post_media_deletion( + pool: &SqlitePool, + paths: &RuntimePaths, + post_id: i64, +) -> anyhow::Result<()> { + for media_id in post_media_ids(pool, post_id).await? { + validate_media_deletion_paths(pool, paths, media_id).await?; + } + Ok(()) +} + +async fn post_media_ids(pool: &SqlitePool, post_id: i64) -> anyhow::Result> { + pool.call(move |conn| { + let mut stmt = + conn.prepare("SELECT media_id FROM post_media WHERE post_id = ? ORDER BY position")?; + let ids = stmt + .query_map([post_id], |row| row.get(0))? + .collect::, _>>()?; + Ok(ids) + }) + .await +} + +async fn remove_media_files(paths: Vec, subject_id: i64, operation: &'static str) { + for path in paths { + if let Err(error) = tokio::fs::remove_file(&path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + subject_id, + path = %path.display(), + error = %error, + operation, + "failed to remove unreferenced media file" + ); + } + } +} + fn promote_canonical_references( tx: &rusqlite::Transaction<'_>, media_id: i64, @@ -1065,6 +1551,7 @@ async fn convert_or_original( }; } Err(err) => { + remove_partial_conversion(&out).await; return original_fallback( original, paths, @@ -1090,6 +1577,7 @@ async fn convert_or_original( }; } Err(err) => { + remove_partial_conversion(&out).await; return original_fallback( original, paths, @@ -1107,6 +1595,20 @@ async fn convert_or_original( original_fallback(original, paths, media_kind, original_mime, "original", "") } +/// Removes a partially written conversion output after an ffmpeg failure or +/// timeout. Missing files are fine. +async fn remove_partial_conversion(path: &Path) { + if let Err(error) = tokio::fs::remove_file(path).await + && error.kind() != std::io::ErrorKind::NotFound + { + tracing::warn!( + path = %path.display(), + error = %error, + "failed to remove partial media conversion output" + ); + } +} + fn original_fallback( original: &Path, paths: &RuntimePaths, @@ -1183,6 +1685,28 @@ mod tests { assert!(reject_path_tricks("C:\\x.png").is_err()); } + #[test] + fn image_dimension_probe_reads_headers() { + let png = tiny_png_bytes(); + assert_eq!(image_dimensions(&png), Some((1, 1))); + + let mut oversized = png; + oversized[16..20].copy_from_slice(&40_000_u32.to_be_bytes()); + oversized[20..24].copy_from_slice(&40_000_u32.to_be_bytes()); + let (width, height) = image_dimensions(&oversized).expect("png dimensions"); + assert_eq!((width, height), (40_000, 40_000)); + assert!(u64::from(width) * u64::from(height) > MAX_IMAGE_PIXELS); + + let mut gif = b"GIF89a".to_vec(); + gif.extend_from_slice(&640u16.to_le_bytes()); + gif.extend_from_slice(&480u16.to_le_bytes()); + gif.extend_from_slice(&[0; 8]); + assert_eq!(image_dimensions(&gif), Some((640, 480))); + + assert_eq!(image_dimensions(b"not an image"), None); + assert_eq!(image_dimensions(&[0xFF, 0xD8]), None); + } + #[test] fn size_limits() { let settings = Settings::default(); @@ -1192,6 +1716,105 @@ mod tests { assert!(classify(&settings, "video/mp4", settings.media.max_video_size + 1).is_err()); } + #[tokio::test] + async fn rejected_malformed_upload_removes_staging_file() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + let bytes = b"not a supported media file"; + let staging = paths.staged_upload_path(&Uuid::new_v4().simple().to_string()); + tokio::fs::write(&staging, bytes).await.expect("staging"); + let context = UploadContext { + pool: &pool, + settings: &settings, + paths: &paths, + ffmpeg: &ffmpeg, + }; + + let error = save_staged_upload( + &context, + StagedUpload { + owner_user_id: Some(user_id), + original_filename: "malformed.png".to_owned(), + staging: StagedUploadGuard::new(staging.clone()), + bytes: u64::try_from(bytes.len()).expect("byte len"), + }, + None, + ) + .await + .expect_err("malformed upload must be rejected"); + + assert!(error.to_string().contains("unsupported media type")); + assert!(!staging.exists()); + assert_eq!(media_count(&pool).await, 0); + assert_tmp_uploads_empty(&paths).await; + } + + #[tokio::test] + async fn rejected_oversize_image_removes_staging_file() { + let (_temp, paths, pool, mut settings, ffmpeg, user_id) = media_fixture().await; + let bytes = tiny_png_bytes(); + settings.media.max_image_size = + u64::try_from(bytes.len().saturating_sub(1)).expect("image size"); + let staging = paths.staged_upload_path(&Uuid::new_v4().simple().to_string()); + tokio::fs::write(&staging, &bytes).await.expect("staging"); + let context = UploadContext { + pool: &pool, + settings: &settings, + paths: &paths, + ffmpeg: &ffmpeg, + }; + + let error = save_staged_upload( + &context, + StagedUpload { + owner_user_id: Some(user_id), + original_filename: "large.png".to_owned(), + staging: StagedUploadGuard::new(staging.clone()), + bytes: u64::try_from(bytes.len()).expect("byte len"), + }, + None, + ) + .await + .expect_err("oversize image must be rejected"); + + assert!(error.to_string().contains("image exceeds maximum size")); + assert!(!staging.exists()); + assert_eq!(media_count(&pool).await, 0); + assert_tmp_uploads_empty(&paths).await; + } + + #[test] + fn staged_upload_guard_drop_removes_armed_file() { + let temp = tempfile::tempdir().expect("temp dir"); + let staging = temp.path().join("upload.tmp"); + std::fs::write(&staging, b"partial upload").expect("staging"); + + { + let _guard = StagedUploadGuard::new(staging.clone()); + } + + assert!(!staging.exists()); + } + + #[tokio::test] + async fn successful_upload_disarms_guard_and_keeps_durable_media() { + let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; + + let media_id = save_test_upload( + &pool, + &settings, + &paths, + &ffmpeg, + user_id, + "Photo.PNG", + &tiny_png_bytes(), + ) + .await; + + let row = media_row(&pool, media_id).await; + assert!(Path::new(&row.stored_path).exists()); + assert_tmp_uploads_empty(&paths).await; + } + #[tokio::test] async fn exact_same_file_uploaded_twice_reuses_canonical_media() { let (_temp, paths, pool, settings, ffmpeg, user_id) = media_fixture().await; @@ -1407,9 +2030,13 @@ mod tests { .await; let second = insert_duplicate_row(&pool, user_id, first, &shared).await; - delete_media(&pool, second).await.expect("delete duplicate"); + delete_media(&pool, &paths, second) + .await + .expect("delete duplicate"); assert!(shared.exists()); - delete_media(&pool, first).await.expect("delete canonical"); + delete_media(&pool, &paths, first) + .await + .expect("delete canonical"); assert!(!shared.exists()); } @@ -1434,7 +2061,9 @@ mod tests { .await; let second = insert_duplicate_row(&pool, user_id, first, &shared).await; - delete_media(&pool, first).await.expect("delete canonical"); + delete_media(&pool, &paths, first) + .await + .expect("delete canonical"); assert!(shared.exists()); let promoted = media_row(&pool, second).await; @@ -1588,7 +2217,9 @@ mod tests { #[tokio::test] async fn profile_media_replacement_deletes_previous_file() { let temp = tempfile::tempdir().expect("temp dir"); - let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("paths"); + let pool = crate::db::connect(&paths.database_path) .await .expect("connect"); crate::db::migrate(&pool).await.expect("migrate"); @@ -1597,9 +2228,9 @@ mod tests { crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) .await .expect("user"); - let first = temp.path().join("first.webp"); - let first_thumb = temp.path().join("first-thumb.webp"); - let second = temp.path().join("second.webp"); + let first = paths.uploads_images.join("first.webp"); + let first_thumb = paths.uploads_thumbs.join("first-thumb.webp"); + let second = paths.uploads_images.join("second.webp"); tokio::fs::write(&first, b"first").await.expect("first"); tokio::fs::write(&first_thumb, b"first-thumb") .await @@ -1609,10 +2240,10 @@ mod tests { let second_id = insert_test_media(&pool, user_id, &second, "image").await; set_test_thumbnail(&pool, first_id, &first_thumb).await; - set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, first_id) + set_profile_media(&pool, &paths, user_id, ProfileMediaSlot::Picture, first_id) .await .expect("set first"); - set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, second_id) + set_profile_media(&pool, &paths, user_id, ProfileMediaSlot::Picture, second_id) .await .expect("set second"); @@ -1624,7 +2255,9 @@ mod tests { #[tokio::test] async fn profile_media_rejects_non_image_and_removes_upload() { let temp = tempfile::tempdir().expect("temp dir"); - let pool = crate::db::connect(&temp.path().join("test.sqlite3")) + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("paths"); + let pool = crate::db::connect(&paths.database_path) .await .expect("connect"); crate::db::migrate(&pool).await.expect("migrate"); @@ -1633,18 +2266,91 @@ mod tests { crate::auth::register_user(&pool, &settings, "alice", "very secure password", false) .await .expect("user"); - let video = temp.path().join("video.webm"); + let video = paths.uploads_videos.join("video.webm"); tokio::fs::write(&video, b"video").await.expect("video"); let media_id = insert_test_media(&pool, user_id, &video, "video").await; assert!( - set_profile_media(&pool, user_id, ProfileMediaSlot::Picture, media_id) + set_profile_media(&pool, &paths, user_id, ProfileMediaSlot::Picture, media_id) .await .is_err() ); assert!(!video.exists()); } + #[tokio::test] + async fn media_deletion_rejects_restored_paths_outside_upload_directories() { + let (temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let outside = temp.path().join("outside.webp"); + tokio::fs::write(&outside, b"private") + .await + .expect("outside"); + let media_id = insert_test_media(&pool, user_id, &outside, "image").await; + + let error = delete_media(&pool, &paths, media_id) + .await + .expect_err("outside path must be rejected"); + + assert!(error.to_string().contains("outside upload directories")); + assert!(outside.exists()); + let row_exists: bool = pool + .call(move |conn| { + conn.query_row( + "SELECT EXISTS(SELECT 1 FROM media WHERE id = ?)", + [media_id], + |row| row.get(0), + ) + .map_err(Into::into) + }) + .await + .expect("media row"); + assert!(row_exists); + } + + #[tokio::test] + async fn deleted_post_media_is_removed_only_after_its_last_reference() { + let (_temp, paths, pool, _settings, _ffmpeg, user_id) = media_fixture().await; + let stored = paths.uploads_images.join("post.webp"); + tokio::fs::write(&stored, b"post") + .await + .expect("post media"); + let media_id = insert_test_media(&pool, user_id, &stored, "image").await; + let (first_post, second_post) = pool + .call(move |conn| { + conn.execute( + "INSERT INTO posts (user_id, text) VALUES (?, 'first')", + [user_id], + )?; + let first = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO posts (user_id, text) VALUES (?, 'second')", + [user_id], + )?; + let second = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, 0)", + params![first, media_id], + )?; + conn.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, 0)", + params![second, media_id], + )?; + Ok((first, second)) + }) + .await + .expect("posts"); + + delete_post_media(&pool, &paths, first_post) + .await + .expect("first cleanup"); + assert!(stored.exists()); + + delete_post_media(&pool, &paths, second_post) + .await + .expect("second cleanup"); + assert!(!stored.exists()); + } + #[cfg(unix)] #[tokio::test] async fn profile_picture_thumbnail_generation_creates_webp_metadata() { @@ -1803,7 +2509,7 @@ mod tests { StagedUpload { owner_user_id: Some(user_id), original_filename: filename.to_owned(), - staging, + staging: StagedUploadGuard::new(staging), bytes: u64::try_from(bytes.len()).expect("byte len"), }, None, @@ -1837,6 +2543,23 @@ mod tests { .expect("media row") } + async fn media_count(pool: &SqlitePool) -> i64 { + pool.call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM media", [], |row| row.get(0))?)) + .await + .expect("media count") + } + + async fn assert_tmp_uploads_empty(paths: &RuntimePaths) { + let mut entries = tokio::fs::read_dir(&paths.tmp_uploads) + .await + .expect("tmp uploads dir"); + let mut leftovers = Vec::new(); + while let Some(entry) = entries.next_entry().await.expect("tmp upload entry") { + leftovers.push(entry.path()); + } + assert!(leftovers.is_empty(), "leftover tmp uploads: {leftovers:?}"); + } + struct TestCanonicalMedia<'a> { stored_path: &'a Path, public_path: &'a str, diff --git a/src/portability.rs b/src/portability.rs new file mode 100644 index 0000000..912bb40 --- /dev/null +++ b/src/portability.rs @@ -0,0 +1,4378 @@ +//! Account export and import archives. +//! +//! `RustPost` user archives are distinct from full-instance backups: they carry +//! one account's posts, profile data, media, outgoing follows, muted words and +//! media bytes, and are imported into an authenticated destination account. +//! Password hashes, sessions, tokens, administrative flags, instance +//! configuration, notifications, interactions (likes/bookmarks/reposts), and +//! other accounts' data are never included. +//! +//! The on-disk format is a gzip-compressed tar archive: +//! +//! ```text +//! manifest.json { format_version, app, created_at, archive_id, username, entry_count } +//! profile.json { display_name, bio, location, website, theme, nsfw_blur_enabled, +//! liked_posts_public, follow_approval_required, created_at } +//! posts.json [ { id, text, created_at, edited_at?, parent_id?, root_id?, +//! quote_id?, media_ids, embeds } ] +//! media.json [ { id, original_filename, mime_type, media_kind, byte_len, +//! alt_text, is_nsfw, conversion_state, sha256, archive_path } ] +//! follows.json [ { username, created_at } ] +//! settings.json { muted_words: [ { term, created_at } ] } +//! media/. one entry per listed media document, bytes included +//! ``` +//! +//! Every entry is validated before any database or filesystem mutation. Media +//! bytes are streamed and hashed, never buffered whole; a bounded prefix is +//! sniffed with `infer` and must match the declared mime type. The compressed +//! size, entry count, decompressed media total, and cumulative document bytes +//! are all enforced incrementally while reading, so a compressed bomb cannot +//! consume unbounded disk or memory. Imports apply in one database transaction +//! plus a staged-file install: media files are copied to the destination +//! upload directories first, the transaction commits the database rows, and +//! copied files are removed again if anything fails. +//! +//! Behaviour decisions worth knowing: +//! +//! * Posts are exported only when authored by the account and not deleted. +//! Reply/root/quote references are kept only when the referenced post is +//! also part of the export, otherwise the reference is dropped (export) or +//! set to `NULL` (import). +//! * Media is exported for media rows owned by the account that are attached +//! to an exported post. `sha256` and `byte_len` are computed from the bytes +//! actually written to the archive, not from the source row. +//! * Import fills empty destination profile text fields (`display_name`, +//! `bio`, `location`, `website`) from the archive and always applies the +//! archive's preference fields (theme, NSFW blur, public likes, follow +//! approval). Conflicts are counted in [`ImportReport`]. +//! * Imported follows resolve against the destination instance by normalized +//! handle. Missing, deleted, suspended, self, and blocked (either direction) +//! targets are skipped; protected targets always produce a pending +//! `follow_requests` row and can never be followed directly. +//! * Imported media keeps the existing content-dedupe invariants: a matching +//! canonical row is reused (no second copy) via `canonical_media_id`. +//! * Usernames, password hashes, administrative flags, suspension, and +//! deletion state are never written from an archive. + +use std::collections::{BTreeMap, BTreeSet, HashMap}; +use std::fs::{self, File, OpenOptions}; +use std::io::{self, Read, Write}; +use std::path::{Component, Path, PathBuf}; + +use anyhow::Context as _; +use chrono::{SecondsFormat, Utc}; +use flate2::Compression; +use flate2::read::GzDecoder; +use flate2::write::GzEncoder; +use rusqlite::{OptionalExtension as _, params}; +use serde::{Deserialize, Serialize}; +use sha2::{Digest as _, Sha256}; +use tar::{Archive, Builder, EntryType, Header}; +use uuid::Uuid; + +use crate::config::Settings; +use crate::db::SqlitePool; +use crate::runtime::RuntimePaths; +use crate::validation::{clean_post_text, normalize_username, validate_profile_text}; + +/// Current archive format version. Importers reject other versions. +pub const FORMAT_VERSION: u32 = 1; +/// Manifest path inside the archive. +pub const MANIFEST_PATH: &str = "manifest.json"; +/// Hard ceiling for one archive, matching the instance upload body limit. +pub const MAX_ARCHIVE_BYTES: u64 = 300 * 1024 * 1024; +/// Hard ceiling for the number of archive entries. +pub const MAX_ENTRIES: usize = 10_000; +/// Prefix used for staged export files under `RuntimePaths::tmp_dir`. +pub const EXPORT_TMP_PREFIX: &str = "account-export-"; +/// Prefix used for staged import uploads under `RuntimePaths::tmp_dir`. +pub const IMPORT_TMP_PREFIX: &str = "account-import-"; + +const APP_NAME: &str = "rustpost"; +const MEDIA_PREFIX: &str = "media/"; +const MEDIA_DIR: &str = "media"; +const PROFILE_PATH: &str = "profile.json"; +const POSTS_PATH: &str = "posts.json"; +const MEDIA_PATH: &str = "media.json"; +const FOLLOWS_PATH: &str = "follows.json"; +const SETTINGS_PATH: &str = "settings.json"; +const DOCUMENT_PATHS: [&str; 6] = [ + MANIFEST_PATH, + PROFILE_PATH, + POSTS_PATH, + MEDIA_PATH, + FOLLOWS_PATH, + SETTINGS_PATH, +]; + +/// Independent limits applied to account archive export and import. +/// +/// `compressed_bytes` bounds the archive file itself (upload and export), +/// while `expanded_bytes` bounds the total decompressed media content. The two +/// limits are deliberately independent: raising the upload ceiling never +/// raises the decompressed ceiling, and a tiny compressed archive can never +/// expand past `expanded_bytes`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct ArchiveLimits { + pub compressed_bytes: u64, + pub expanded_bytes: u64, + pub entries: usize, +} + +impl ArchiveLimits { + #[must_use] + pub fn from_settings(settings: &Settings) -> Self { + Self { + compressed_bytes: settings.accounts.max_archive_upload_bytes, + expanded_bytes: settings.accounts.max_archive_expanded_bytes, + entries: settings.accounts.max_archive_entries, + } + } +} + +/// Records that must be duplicated rather than silently truncated. +const MAX_POSTS: usize = 5_000; +const MAX_MEDIA: usize = 1_000; +const MAX_FOLLOWS: usize = 5_000; +const MAX_MUTED_WORDS: usize = 500; +const MAX_EMBEDS_PER_POST: usize = 32; +const MAX_ALT_TEXT_CHARS: usize = 4_096; +const MAX_ORIGINAL_FILENAME_CHARS: usize = 255; +const MAX_CONVERSION_STATE_CHARS: usize = 64; +const MAX_EMBED_TITLE_CHARS: usize = 512; +const MAX_EMBED_URL_CHARS: usize = 2_048; +const MAX_EMBED_VIDEO_ID_CHARS: usize = 128; +const MAX_LOCATION_CHARS: usize = 100; +const MAX_WEBSITE_CHARS: usize = 2_048; +const MAX_TIMESTAMP_CHARS: usize = 64; +const MANIFEST_MAX_BYTES: u64 = 256 * 1024; +const DOCUMENT_MAX_BYTES: u64 = 16 * 1024 * 1024; +/// Cumulative ceiling for every JSON document in one archive, in addition to +/// the per-document caps. Six maximum-size documents would otherwise hold up +/// to 96 MiB in memory at once. +const MAX_TOTAL_DOCUMENT_BYTES: u64 = 64 * 1024 * 1024; +/// Number of leading media bytes collected while streaming extraction so the +/// `infer` crate can check the content type without buffering whole entries. +const MEDIA_SNIFF_PREFIX_BYTES: usize = 512; +/// Longest accepted media entry file name, in bytes. Longer names are rejected +/// because they cannot be represented safely on common filesystems. +const MAX_MEDIA_FILE_NAME_BYTES: usize = 255; +const HASH_PREFIX_LEN: usize = 16; +const COPY_BUFFER_BYTES: usize = 64 * 1024; +const ALREADY_IMPORTED_MESSAGE: &str = "this archive has already been imported into this account"; + +/// `SQLite` extended result codes for the `UNIQUE(user_id, archive_id)` +/// constraint on `account_imports`. +const SQLITE_CONSTRAINT_UNIQUE: i32 = 2_067; +const SQLITE_CONSTRAINT_PRIMARYKEY: i32 = 1_555; + +const FOLLOW_NOTIFICATION_MESSAGE: &str = "followed you"; +const FOLLOW_REQUEST_NOTIFICATION_MESSAGE: &str = "requested to follow you"; + +/// Result of writing one account archive. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct ExportReport { + pub archive_path: PathBuf, + pub archive_id: String, + pub bytes: u64, + pub posts: usize, + pub media: usize, + pub follows: usize, + pub muted_words: usize, +} + +/// Result of importing one account archive into a destination account. +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct ImportReport { + pub archive_id: String, + pub archive_username: String, + pub posts_imported: usize, + pub media_imported: usize, + pub follows_imported: usize, + pub follows_pending: usize, + pub follows_skipped: usize, + /// Reply/root/quote references in the archive that pointed at posts which + /// are not part of the archive and were therefore dropped. + pub post_references_dropped: usize, + pub muted_words_imported: usize, + pub profile_fields_applied: usize, + pub profile_fields_skipped: usize, +} + +// --------------------------------------------------------------------------- +// Archive documents +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ManifestDocument { + format_version: u32, + app: String, + created_at: String, + archive_id: String, + username: String, + entry_count: usize, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct ProfileDocument { + display_name: String, + bio: String, + location: String, + website: String, + theme: String, + nsfw_blur_enabled: bool, + liked_posts_public: bool, + follow_approval_required: bool, + created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct PostDocument { + id: i64, + text: String, + created_at: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + edited_at: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + parent_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + root_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + quote_id: Option, + #[serde(default)] + media_ids: Vec, + #[serde(default)] + embeds: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct EmbedDocument { + provider: String, + video_id: String, + original_url: String, + canonical_url: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + title: Option, + thumbnail_url: String, + embed_url: String, + position: i64, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MediaDocument { + id: i64, + original_filename: String, + mime_type: String, + media_kind: String, + byte_len: u64, + alt_text: String, + is_nsfw: bool, + conversion_state: String, + sha256: String, + archive_path: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct FollowDocument { + username: String, + created_at: String, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct SettingsDocument { + muted_words: Vec, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +struct MutedWordDocument { + term: String, + created_at: String, +} + +// --------------------------------------------------------------------------- +// Export +// --------------------------------------------------------------------------- + +/// Writes a complete archive for `user_id` into `destination`. +/// +/// `destination` must name a file inside an existing directory; the archive is +/// written to a temporary sibling and renamed into place, so a failed export +/// never leaves a partial file. A `..` component, a symlink, a non-file +/// destination, or a missing parent directory is rejected. The caller usually +/// stages under `RuntimePaths::tmp_dir` using [`EXPORT_TMP_PREFIX`]. +#[expect( + clippy::too_many_lines, + reason = "export builds the manifest, documents, and media entries in one ordered pass" +)] +pub async fn export_account( + pool: &SqlitePool, + paths: &RuntimePaths, + user_id: i64, + destination: &Path, + limits: ArchiveLimits, +) -> anyhow::Result { + let destination_dir = validate_export_destination(destination)?; + let snapshot = load_export_snapshot(pool, user_id).await?; + + let mut media_plans = Vec::with_capacity(snapshot.media.len()); + let mut media_archive_ids: HashMap = HashMap::with_capacity(snapshot.media.len()); + for (index, media) in snapshot.media.iter().enumerate() { + let archive_id = i64::try_from(index)?; + let source = validate_export_media_source( + paths, + &media.media_kind, + &media.mime_type, + &media.stored_path, + )?; + let (byte_len, sha256) = hash_file_streaming(&source)?; + let extension = export_media_extension(&media.mime_type, &media.media_kind); + media_archive_ids.insert(media.id, archive_id); + media_plans.push(ExportMediaPlan { + archive_id, + archive_path: format!("{MEDIA_PREFIX}{index}.{extension}"), + source, + byte_len, + sha256, + original_filename: media.original_filename.clone(), + mime_type: media.mime_type.clone(), + media_kind: media.media_kind.clone(), + alt_text: media.alt_text.clone(), + is_nsfw: media.is_nsfw, + conversion_state: media.conversion_state.clone(), + }); + } + + let post_ids = snapshot + .posts + .iter() + .map(|post| post.id) + .collect::>(); + let mut posts = Vec::with_capacity(snapshot.posts.len()); + let mut dropped_references = 0usize; + for post in &snapshot.posts { + let parent_id = keep_exported_reference(post.parent_post_id, &post_ids); + let root_id = keep_exported_reference(post.root_post_id, &post_ids); + let quote_id = keep_exported_reference(post.quote_post_id, &post_ids); + dropped_references += usize::from(post.parent_post_id.is_some() && parent_id.is_none()) + + usize::from(post.root_post_id.is_some() && root_id.is_none()) + + usize::from(post.quote_post_id.is_some() && quote_id.is_none()); + posts.push(PostDocument { + id: post.id, + text: post.text.clone(), + created_at: post.created_at.clone(), + edited_at: post.edited_at.clone(), + parent_id, + root_id, + quote_id, + media_ids: post + .media_ids + .iter() + .filter_map(|id| media_archive_ids.get(id).copied()) + .collect(), + embeds: post.embeds.clone(), + }); + } + if dropped_references > 0 { + // `ExportReport` has no counter for this, so record it in the log. + tracing::info!( + user_id, + dropped_references, + "account export dropped post references whose target is not part of the archive" + ); + } + + let entry_count = DOCUMENT_PATHS.len() + media_plans.len(); + if entry_count > limits.entries { + anyhow::bail!( + "account archive would contain {entry_count} entries; the limit is {}", + limits.entries + ); + } + let manifest = ManifestDocument { + format_version: FORMAT_VERSION, + app: APP_NAME.to_owned(), + created_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true), + archive_id: Uuid::new_v4().to_string(), + username: snapshot.username.clone(), + entry_count, + }; + let settings_document = snapshot.muted_words_document(); + let documents = ExportDocuments { + manifest: &manifest, + profile: &snapshot.profile, + posts: &posts, + follows: &snapshot.follows, + settings: &settings_document, + media_plans: &media_plans, + }; + let bytes = write_export_archive(destination, &destination_dir, &documents, limits)?; + + Ok(ExportReport { + archive_path: destination.to_path_buf(), + archive_id: manifest.archive_id, + bytes, + posts: posts.len(), + media: media_plans.len(), + follows: snapshot.follows.len(), + muted_words: snapshot.muted_words.len(), + }) +} + +/// Rejects destinations that are not a plain file name inside an existing +/// directory, and returns that parent directory. +fn validate_export_destination(destination: &Path) -> anyhow::Result { + if destination + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + anyhow::bail!("export destination must not contain '..' components"); + } + let Some(file_name) = destination.file_name().and_then(|name| name.to_str()) else { + anyhow::bail!("export destination must be a plain file name"); + }; + if file_name.is_empty() || file_name == "." || file_name == ".." { + anyhow::bail!("export destination must be a plain file name"); + } + if file_name.contains(['/', '\\', ':']) || file_name.chars().any(char::is_control) { + anyhow::bail!("export destination file name contains unsupported characters"); + } + match fs::symlink_metadata(destination) { + Ok(metadata) => { + if metadata.file_type().is_symlink() { + anyhow::bail!("export destination must not be a symbolic link"); + } + if !metadata.is_file() { + anyhow::bail!("export destination already exists and is not a regular file"); + } + } + Err(error) if error.kind() == io::ErrorKind::NotFound => {} + Err(error) => { + return Err(error).with_context(|| { + format!( + "failed to inspect export destination {}", + destination.display() + ) + }); + } + } + let parent = destination + .parent() + .filter(|parent| !parent.as_os_str().is_empty()) + .map_or_else(|| PathBuf::from("."), Path::to_path_buf); + let metadata = fs::metadata(&parent).with_context(|| { + format!( + "export destination directory {} does not exist", + parent.display() + ) + })?; + if !metadata.is_dir() { + anyhow::bail!("export destination parent is not a directory"); + } + Ok(parent) +} + +/// Keeps a post reference only when the referenced post is also exported. +fn keep_exported_reference(reference: Option, exported: &BTreeSet) -> Option { + reference.filter(|id| exported.contains(id)) +} + +struct ExportSnapshot { + username: String, + profile: ProfileDocument, + posts: Vec, + media: Vec, + follows: Vec, + muted_words: Vec, +} + +impl ExportSnapshot { + fn muted_words_document(&self) -> SettingsDocument { + SettingsDocument { + muted_words: self.muted_words.clone(), + } + } +} + +struct ExportPostRow { + id: i64, + text: String, + created_at: String, + edited_at: Option, + parent_post_id: Option, + root_post_id: Option, + quote_post_id: Option, + media_ids: Vec, + embeds: Vec, +} + +struct ExportMediaRow { + id: i64, + original_filename: String, + mime_type: String, + media_kind: String, + alt_text: String, + is_nsfw: bool, + conversion_state: String, + stored_path: String, +} + +/// Reads every exportable record in one consistent snapshot. Only non-deleted +/// posts authored by the account are considered, and media is limited to rows +/// owned by the account that are attached to one of those posts. +#[expect( + clippy::too_many_lines, + reason = "one consistent read of every exportable record family keeps the snapshot coherent" +)] +async fn load_export_snapshot(pool: &SqlitePool, user_id: i64) -> anyhow::Result { + pool.call(move |conn| { + let tx = conn.transaction()?; + let account = tx + .query_row( + r#" + SELECT username, display_name, bio, location, website, theme, + nsfw_blur_enabled, liked_posts_public, follow_approval_required, created_at + FROM users WHERE id = ? AND is_deleted = 0 + "#, + [user_id], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, String>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, i64>(6)? != 0, + row.get::<_, i64>(7)? != 0, + row.get::<_, i64>(8)? != 0, + row.get::<_, String>(9)?, + )) + }, + ) + .optional()?; + let Some(( + username, + display_name, + bio, + location, + website, + theme, + nsfw_blur_enabled, + liked_posts_public, + follow_approval_required, + created_at, + )) = account + else { + anyhow::bail!("account does not exist or has been deleted"); + }; + let profile = ProfileDocument { + display_name, + bio, + location, + website, + theme, + nsfw_blur_enabled, + liked_posts_public, + follow_approval_required, + created_at, + }; + + let mut posts = { + let mut statement = tx.prepare( + r#" + SELECT id, text, created_at, edited_at, parent_post_id, root_post_id, quote_post_id + FROM posts WHERE user_id = ? AND is_deleted = 0 ORDER BY id ASC + "#, + )?; + statement + .query_map([user_id], |row| { + Ok(ExportPostRow { + id: row.get(0)?, + text: row.get(1)?, + created_at: row.get(2)?, + edited_at: row.get(3)?, + parent_post_id: row.get(4)?, + root_post_id: row.get(5)?, + quote_post_id: row.get(6)?, + media_ids: Vec::new(), + embeds: Vec::new(), + }) + })? + .collect::, _>>()? + }; + let post_index: HashMap = posts + .iter() + .enumerate() + .map(|(index, post)| (post.id, index)) + .collect(); + { + let mut statement = tx.prepare( + r#" + SELECT pm.post_id, pm.media_id + FROM post_media pm + JOIN posts p ON p.id = pm.post_id + WHERE p.user_id = ? AND p.is_deleted = 0 + ORDER BY pm.post_id ASC, pm.position ASC, pm.media_id ASC + "#, + )?; + for row in statement.query_map([user_id], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)?)) + })? { + let (post_id, media_id) = row?; + if let Some(index) = post_index.get(&post_id) { + posts[*index].media_ids.push(media_id); + } + } + } + { + let mut statement = tx.prepare( + r#" + SELECT post_id, provider, video_id, original_url, canonical_url, title, + thumbnail_url, embed_url, position + FROM post_embeds + WHERE post_id IN (SELECT id FROM posts WHERE user_id = ? AND is_deleted = 0) + ORDER BY post_id ASC, position ASC + "#, + )?; + for row in statement.query_map([user_id], |row| { + Ok(( + row.get::<_, i64>(0)?, + EmbedDocument { + provider: row.get(1)?, + video_id: row.get(2)?, + original_url: row.get(3)?, + canonical_url: row.get(4)?, + title: row.get(5)?, + thumbnail_url: row.get(6)?, + embed_url: row.get(7)?, + position: row.get(8)?, + }, + )) + })? { + let (post_id, embed) = row?; + if let Some(index) = post_index.get(&post_id) { + posts[*index].embeds.push(embed); + } + } + } + + let media = { + let mut statement = tx.prepare( + r#" + SELECT DISTINCT m.id, m.original_filename, m.mime_type, m.media_kind, + m.alt_text, m.is_nsfw, m.conversion_state, m.stored_path + FROM media m + JOIN post_media pm ON pm.media_id = m.id + JOIN posts p ON p.id = pm.post_id + WHERE p.user_id = ? AND p.is_deleted = 0 AND m.owner_user_id = ? + ORDER BY m.id ASC + "#, + )?; + statement + .query_map(params![user_id, user_id], |row| { + Ok(ExportMediaRow { + id: row.get(0)?, + original_filename: row.get(1)?, + mime_type: row.get(2)?, + media_kind: row.get(3)?, + alt_text: row.get(4)?, + is_nsfw: row.get::<_, i64>(5)? != 0, + conversion_state: row.get(6)?, + stored_path: row.get(7)?, + }) + })? + .collect::, _>>()? + }; + + let follows = { + let mut statement = tx.prepare( + r#" + SELECT u.username, f.created_at + FROM follows f JOIN users u ON u.id = f.followed_id + WHERE f.follower_id = ? + ORDER BY u.normalized_username ASC, u.id ASC + "#, + )?; + statement + .query_map([user_id], |row| { + Ok(FollowDocument { + username: row.get(0)?, + created_at: row.get(1)?, + }) + })? + .collect::, _>>()? + }; + + let muted_words = { + let mut statement = tx.prepare( + "SELECT term, created_at FROM muted_words WHERE user_id = ? ORDER BY normalized_term ASC, id ASC", + )?; + statement + .query_map([user_id], |row| { + Ok(MutedWordDocument { + term: row.get(0)?, + created_at: row.get(1)?, + }) + })? + .collect::, _>>()? + }; + + tx.commit()?; + Ok(ExportSnapshot { + username, + profile, + posts, + media, + follows, + muted_words, + }) + }) + .await +} + +/// Confirms a stored media path is a regular file inside the upload roots the +/// media kind may live in. Stored paths come from the database, which must be +/// treated as untrusted after a restore. +fn validate_export_media_source( + paths: &RuntimePaths, + media_kind: &str, + mime_type: &str, + stored_path: &str, +) -> anyhow::Result { + let allowed_roots: [&Path; 2] = match media_kind { + "image" => [&paths.uploads_images, &paths.uploads_originals], + "video" => [&paths.uploads_videos, &paths.uploads_originals], + _ => anyhow::bail!("media row has an unsupported media kind"), + }; + if mime_type.is_empty() || !mime_type.starts_with(&format!("{media_kind}/")) { + anyhow::bail!("media row mime type does not match its media kind"); + } + let path = PathBuf::from(stored_path); + if !path.is_absolute() + || path + .components() + .any(|component| matches!(component, Component::ParentDir)) + { + anyhow::bail!("stored media path is not a safe absolute path"); + } + if !allowed_roots.iter().any(|root| path.starts_with(root)) { + anyhow::bail!("stored media path is outside the upload directories"); + } + let metadata = fs::symlink_metadata(&path) + .with_context(|| format!("media file {} is missing", path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + anyhow::bail!("stored media path is not a regular file"); + } + Ok(path) +} + +struct ExportMediaPlan { + archive_id: i64, + archive_path: String, + source: PathBuf, + byte_len: u64, + sha256: String, + original_filename: String, + mime_type: String, + media_kind: String, + alt_text: String, + is_nsfw: bool, + conversion_state: String, +} + +impl ExportMediaPlan { + fn document(&self) -> MediaDocument { + MediaDocument { + id: self.archive_id, + original_filename: self.original_filename.clone(), + mime_type: self.mime_type.clone(), + media_kind: self.media_kind.clone(), + byte_len: self.byte_len, + alt_text: self.alt_text.clone(), + is_nsfw: self.is_nsfw, + conversion_state: self.conversion_state.clone(), + sha256: self.sha256.clone(), + archive_path: self.archive_path.clone(), + } + } +} + +struct ExportDocuments<'a> { + manifest: &'a ManifestDocument, + profile: &'a ProfileDocument, + posts: &'a [PostDocument], + follows: &'a [FollowDocument], + settings: &'a SettingsDocument, + media_plans: &'a [ExportMediaPlan], +} + +type ExportBuilder = Builder>>; + +/// Writes the gzip tar archive to a temporary sibling and renames it into +/// place. The temporary file is removed on every failure path. +fn write_export_archive( + destination: &Path, + destination_dir: &Path, + documents: &ExportDocuments<'_>, + limits: ArchiveLimits, +) -> anyhow::Result { + let Some(file_name) = destination.file_name().and_then(|name| name.to_str()) else { + anyhow::bail!("export destination must be a plain file name"); + }; + let temp_path = destination_dir.join(format!(".{file_name}.{}.tmp", Uuid::new_v4().simple())); + let mut guard = TempOutputGuard::new(temp_path.clone()); + let file = OpenOptions::new() + .write(true) + .create_new(true) + .open(&temp_path) + .with_context(|| format!("failed to create account archive {}", temp_path.display()))?; + let writer = CappedWriter::new(file, limits.compressed_bytes); + let encoder = GzEncoder::new(writer, Compression::default()); + let mut builder = Builder::new(encoder); + + append_json_entry(&mut builder, MANIFEST_PATH, documents.manifest)?; + append_json_entry(&mut builder, PROFILE_PATH, documents.profile)?; + append_json_entry(&mut builder, POSTS_PATH, documents.posts)?; + let media_documents = documents + .media_plans + .iter() + .map(ExportMediaPlan::document) + .collect::>(); + append_json_entry(&mut builder, MEDIA_PATH, &media_documents)?; + append_json_entry(&mut builder, FOLLOWS_PATH, documents.follows)?; + append_json_entry(&mut builder, SETTINGS_PATH, documents.settings)?; + for plan in documents.media_plans { + append_media_entry(&mut builder, plan)?; + } + + builder + .finish() + .with_context(|| "failed to finish account archive")?; + let encoder = builder + .into_inner() + .context("failed to finish account archive")?; + let writer = encoder + .finish() + .context("failed to finish account archive")?; + let bytes = writer.written; + let file = writer.into_inner(); + file.sync_all() + .with_context(|| "failed to flush account archive")?; + drop(file); + fs::rename(&temp_path, destination).with_context(|| { + format!( + "failed to install account archive {}", + destination.display() + ) + })?; + guard.disarm(); + Ok(bytes) +} + +fn append_json_entry( + builder: &mut ExportBuilder, + archive_path: &str, + value: &T, +) -> anyhow::Result<()> { + let bytes = serde_json::to_vec(value)?; + let size = u64::try_from(bytes.len())?; + let header = archive_entry_header(archive_path, size)?; + builder + .append(&header, bytes.as_slice()) + .with_context(|| format!("failed to write archive entry {archive_path}"))?; + Ok(()) +} + +fn append_media_entry(builder: &mut ExportBuilder, plan: &ExportMediaPlan) -> anyhow::Result<()> { + let mut source = File::open(&plan.source) + .with_context(|| format!("failed to open media file {}", plan.source.display()))?; + let header = archive_entry_header(&plan.archive_path, plan.byte_len)?; + builder + .append(&header, &mut source) + .with_context(|| format!("failed to write archive entry {}", plan.archive_path))?; + Ok(()) +} + +fn archive_entry_header(archive_path: &str, size: u64) -> anyhow::Result
{ + let mut header = Header::new_ustar(); + header.set_path(archive_path)?; + header.set_entry_type(EntryType::Regular); + header.set_size(size); + header.set_mode(0o600); + header.set_uid(0); + header.set_gid(0); + header.set_mtime(0); + header.set_cksum(); + Ok(header) +} + +/// Removes the staged archive if it was not renamed into place. +struct TempOutputGuard { + path: PathBuf, + armed: bool, +} + +impl TempOutputGuard { + fn new(path: PathBuf) -> Self { + Self { path, armed: true } + } + + fn disarm(&mut self) { + self.armed = false; + } +} + +impl Drop for TempOutputGuard { + fn drop(&mut self) { + if !self.armed { + return; + } + if let Err(error) = fs::remove_file(&self.path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::debug!( + path = %self.path.display(), + error = %error, + "failed to remove staged account archive" + ); + } + } +} + +/// Counts written bytes and fails once the archive would exceed `limit`. +/// Wrapping the gzip writer means the cap applies to the compressed archive +/// size, which is what the upload limit constrains. +struct CappedWriter { + inner: W, + written: u64, + limit: u64, +} + +impl CappedWriter { + fn new(inner: W, limit: u64) -> Self { + Self { + inner, + written: 0, + limit, + } + } + + fn into_inner(self) -> W { + self.inner + } +} + +impl Write for CappedWriter { + fn write(&mut self, buffer: &[u8]) -> io::Result { + let next = self + .written + .saturating_add(u64::try_from(buffer.len()).unwrap_or(u64::MAX)); + if next > self.limit { + return Err(io::Error::other(format!( + "account archive exceeds the {}-byte limit", + self.limit + ))); + } + let written = self.inner.write(buffer)?; + self.written = self + .written + .saturating_add(u64::try_from(written).unwrap_or(u64::MAX)); + Ok(written) + } + + fn flush(&mut self) -> io::Result<()> { + self.inner.flush() + } +} + +fn hash_file_streaming(path: &Path) -> anyhow::Result<(u64, String)> { + let mut file = File::open(path) + .with_context(|| format!("failed to open media file {}", path.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_BYTES]; + let mut size = 0_u64; + loop { + let read = file.read(&mut buffer)?; + if read == 0 { + break; + } + size = size.saturating_add(u64::try_from(read)?); + hasher.update(&buffer[..read]); + } + Ok((size, hex_lower(hasher.finalize().as_ref()))) +} + +/// Maps an archive mime type to a sanitized extension. The stored host path +/// and original file name are never used for archive entry names. +fn export_media_extension(mime_type: &str, media_kind: &str) -> &'static str { + match (media_kind, mime_type) { + ("image", "image/jpeg") => "jpg", + ("image", "image/png") => "png", + ("image", "image/gif") => "gif", + ("image", "image/webp") => "webp", + ("image", _) => "img", + ("video", "video/webm") => "webm", + ("video", "video/quicktime") => "mov", + ("video", _) => "mp4", + _ => "bin", + } +} + +// --------------------------------------------------------------------------- +// Import: archive validation and extraction +// --------------------------------------------------------------------------- + +/// Validates and imports `archive_path` into the authenticated destination +/// account `user_id`. +/// +/// Nothing is written until the whole archive validates. Media files are +/// copied from the staging directory into the destination upload directories +/// first, then one database transaction inserts every row; if the transaction +/// fails the copied files are removed. Imported follows respect protected +/// accounts by creating pending requests, and archives cannot change the +/// destination username, password, or administrator status. +pub async fn import_account( + pool: &SqlitePool, + paths: &RuntimePaths, + settings: &Settings, + user_id: i64, + archive_path: &Path, +) -> anyhow::Result { + // 1. Extract and validate the entire archive before any mutation. + fs::create_dir_all(&paths.tmp_dir).with_context(|| { + format!( + "failed to create account import staging directory {}", + paths.tmp_dir.display() + ) + })?; + let staging = tempfile::Builder::new() + .prefix(IMPORT_TMP_PREFIX) + .tempdir_in(&paths.tmp_dir) + .with_context(|| "failed to create account import staging directory")?; + let limits = ArchiveLimits::from_settings(settings); + let extracted = extract_archive(archive_path, staging.path(), limits)?; + let staged = parse_archive(settings, &extracted, limits)?; + drop(extracted); + + // 2. Destination and repeat-import checks, still before any mutation. + ensure_import_destination(pool, user_id).await?; + if archive_already_imported(pool, user_id, &staged.manifest.archive_id).await? { + anyhow::bail!(ALREADY_IMPORTED_MESSAGE); + } + + // 3. Copy media bytes into the upload directories, claiming unique names. + let installed = install_media_files(pool, paths, settings, &staged.media).await?; + + // 4. Apply every database row in one transaction. The transaction reads + // the installed file paths, so the files must exist before it starts. + let plan = ImportPlan { + archive_id: staged.manifest.archive_id, + archive_username: staged.manifest.username, + profile: staged.profile, + posts: staged.posts, + media: staged.media, + media_install: installed.decisions, + follows: staged.follows, + muted_words: staged.muted_words, + }; + let max_username_len = settings.accounts.max_username_len; + let result = apply_import(pool, user_id, max_username_len, plan).await; + match result { + Ok((report, unused_files)) => { + // Cleanup order: files that turned out to duplicate an existing + // canonical row are only removed after the transaction commits. + remove_files_best_effort(&unused_files); + Ok(report) + } + Err(error) => { + // Cleanup order: the transaction rolled back, so remove every file + // copied into the upload directories. The staging directory removes + // itself when `staging` drops at the end of this scope. + remove_files_best_effort(&installed.installed_paths); + Err(error) + } + } +} + +/// Everything read from a validated archive, plus the staging location of the +/// media bytes. Documents are small JSON buffers; media stays on disk. +struct ExtractedArchive { + documents: BTreeMap>, + media_files: BTreeMap, + entry_count: usize, +} + +struct ExtractedMediaFile { + staged_path: PathBuf, + size: u64, + sha256: String, + /// Bounded copy of the entry's first bytes, used for content sniffing. + prefix: Vec, +} + +/// Extracts the archive into `staging`, enforcing structural limits. Every +/// path, entry type, duplicate name, and size is checked before a single byte +/// is written outside the staging directory, and byte limits are enforced +/// incrementally while entries stream so a compressed bomb never expands past +/// `limits.expanded_bytes` on disk or in memory. +#[expect( + clippy::too_many_lines, + reason = "one ordered pass validates paths, documents, and streamed media together" +)] +fn extract_archive( + archive_path: &Path, + staging: &Path, + limits: ArchiveLimits, +) -> anyhow::Result { + let metadata = fs::symlink_metadata(archive_path) + .with_context(|| format!("account archive {} does not exist", archive_path.display()))?; + if metadata.file_type().is_symlink() || !metadata.is_file() { + anyhow::bail!("account archive is not a regular file"); + } + if metadata.len() > limits.compressed_bytes { + anyhow::bail!( + "account archive is too large ({} bytes); the limit is {} bytes", + metadata.len(), + limits.compressed_bytes + ); + } + + let file = File::open(archive_path) + .with_context(|| format!("failed to open account archive {}", archive_path.display()))?; + let decoder = GzDecoder::new(file); + let mut archive = Archive::new(decoder); + let entries = archive + .entries() + .with_context(|| "account archive is not a valid gzip tar archive")?; + + let mut documents = BTreeMap::new(); + let mut media_files = BTreeMap::new(); + let mut seen = BTreeSet::new(); + let mut entry_count = 0usize; + let mut total_document_bytes = 0u64; + let mut extracted_media_bytes = 0u64; + + for entry in entries { + let mut entry = entry.with_context(|| "account archive is not a valid gzip tar archive")?; + entry_count += 1; + if entry_count > limits.entries { + anyhow::bail!( + "account archive contains more than {} entries", + limits.entries + ); + } + let (name, entry_type) = validate_entry_path(&entry)?; + // Extraction targets can live on case-insensitive filesystems, so + // `media/0.png` and `media/0.PNG` must collide before either is written. + if !seen.insert(name.to_ascii_lowercase()) { + anyhow::bail!("account archive contains duplicate entry {name}"); + } + if entry_type.is_dir() { + if name != MEDIA_DIR { + anyhow::bail!("account archive contains unexpected directory {name}"); + } + continue; + } + if !entry_type.is_file() { + anyhow::bail!("account archive contains an unsupported entry type for {name}"); + } + if DOCUMENT_PATHS.contains(&name.as_str()) { + let limit = document_limit(&name); + let bytes = read_limited_entry(&mut entry, limit) + .with_context(|| format!("{name} is too large or unreadable"))?; + total_document_bytes = total_document_bytes + .checked_add(u64::try_from(bytes.len())?) + .ok_or_else(|| anyhow::anyhow!("account archive document size overflow"))?; + if total_document_bytes > MAX_TOTAL_DOCUMENT_BYTES { + anyhow::bail!( + "account archive JSON documents exceed the {MAX_TOTAL_DOCUMENT_BYTES}-byte total limit" + ); + } + documents.insert(name, bytes); + } else if let Some(relative) = name.strip_prefix(MEDIA_PREFIX) { + let relative_path = Path::new(relative); + let valid_relative = !relative.is_empty() + && relative_path + .components() + .all(|component| matches!(component, Component::Normal(_))); + if !valid_relative || relative_path.components().count() != 1 { + anyhow::bail!("account archive contains an invalid media path {name}"); + } + // Reject an oversized entry from its header before reading any of + // its bytes so a declared giant size cannot consume disk. + let declared_size = entry + .header() + .size() + .with_context(|| format!("account archive entry {name} has an invalid size"))?; + if declared_size > limits.expanded_bytes { + anyhow::bail!( + "account archive media entry {name} declares {declared_size} bytes; the limit is {} bytes", + limits.expanded_bytes + ); + } + // A valid archive can never list more media documents than + // `media.json` accepts, so stop extracting sooner than the general + // entry cap allows. + if media_files.len() >= MAX_MEDIA { + anyhow::bail!("account archive contains more than {MAX_MEDIA} media files"); + } + let media_dir = staging.join(MEDIA_DIR); + fs::create_dir_all(&media_dir)?; + let target = media_dir.join(relative); + let (size, sha256, prefix, total) = extract_media_entry( + &mut entry, + &target, + extracted_media_bytes, + limits.expanded_bytes, + )?; + extracted_media_bytes = total; + media_files.insert( + name, + ExtractedMediaFile { + staged_path: target, + size, + sha256, + prefix, + }, + ); + } else { + anyhow::bail!("account archive contains unexpected entry {name}"); + } + } + + for document in DOCUMENT_PATHS { + if !documents.contains_key(document) { + anyhow::bail!("account archive is missing required document {document}"); + } + } + Ok(ExtractedArchive { + documents, + media_files, + entry_count, + }) +} + +/// Validates one tar path and returns its normalized name and type. +fn validate_entry_path(entry: &tar::Entry<'_, impl Read>) -> anyhow::Result<(String, EntryType)> { + let entry_type = entry.header().entry_type(); + if !entry_type.is_file() && !entry_type.is_dir() { + anyhow::bail!("account archive contains an unsupported entry type"); + } + let raw = entry.path_bytes(); + let raw = std::str::from_utf8(raw.as_ref()) + .map_err(|_utf8| anyhow::anyhow!("account archive contains a non-UTF-8 path"))?; + if raw.is_empty() || raw.starts_with('/') { + anyhow::bail!("account archive contains an absolute or empty path"); + } + // Archives written by RustPost only use ASCII names; anything else is + // almost certainly crafted to confuse normalization or filesystems. + if !raw.is_ascii() { + anyhow::bail!("account archive contains a non-ASCII path"); + } + if raw.contains('\\') + || raw.contains(':') + || raw.contains("//") + || raw.contains('\u{2215}') + || raw.contains('\u{2044}') + || raw.contains('\u{29f8}') + || raw.contains('\u{ff0f}') + { + anyhow::bail!("account archive path contains unsafe characters"); + } + let lower = raw.to_ascii_lowercase(); + if lower.contains("%2e") || lower.contains("%2f") || lower.contains("%5c") { + anyhow::bail!("account archive path contains unsafe characters"); + } + let normalized = if entry_type.is_dir() { + raw.strip_suffix('/').unwrap_or(raw) + } else { + raw + }; + if normalized.is_empty() || normalized.contains("//") || normalized.ends_with('/') { + anyhow::bail!("account archive path contains unsafe separators"); + } + let path = Path::new(normalized); + if path.is_absolute() + || path + .components() + .any(|component| !matches!(component, Component::Normal(_))) + { + anyhow::bail!("account archive entry path contains traversal"); + } + if let Some(relative) = normalized.strip_prefix(MEDIA_PREFIX) + && !relative.is_empty() + { + let file_name = Path::new(relative) + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or(relative); + if file_name.len() > MAX_MEDIA_FILE_NAME_BYTES { + anyhow::bail!("account archive media entry name is too long"); + } + if file_name.ends_with('.') || file_name.ends_with(' ') { + anyhow::bail!("account archive media entry name ends with '.' or a space"); + } + } + Ok((normalized.to_owned(), entry_type)) +} + +fn document_limit(name: &str) -> u64 { + if name == MANIFEST_PATH { + MANIFEST_MAX_BYTES + } else { + DOCUMENT_MAX_BYTES + } +} + +fn read_limited_entry( + entry: &mut tar::Entry<'_, impl Read>, + limit: u64, +) -> anyhow::Result> { + let mut buffer = Vec::new(); + let mut limited = entry.take(limit.saturating_add(1)); + limited.read_to_end(&mut buffer)?; + if u64::try_from(buffer.len()).unwrap_or(u64::MAX) > limit { + anyhow::bail!("document exceeds the {limit}-byte limit"); + } + Ok(buffer) +} + +/// Streams one media entry into staging while hashing it, collecting a small +/// content-sniffing prefix, and enforcing the per-entry and running-total byte +/// caps. Memory use is one fixed-size buffer plus the bounded prefix. +fn extract_media_entry( + entry: &mut tar::Entry<'_, impl Read>, + target: &Path, + already_extracted: u64, + expanded_limit: u64, +) -> anyhow::Result<(u64, String, Vec, u64)> { + let mut output = OpenOptions::new() + .write(true) + .create_new(true) + .open(target) + .with_context(|| format!("failed to extract media file {}", target.display()))?; + let mut hasher = Sha256::new(); + let mut buffer = vec![0_u8; COPY_BUFFER_BYTES]; + let mut prefix = Vec::with_capacity(MEDIA_SNIFF_PREFIX_BYTES); + let mut size = 0_u64; + let mut total = already_extracted; + loop { + let read = entry.read(&mut buffer).with_context(|| { + format!( + "failed to read account archive media entry for {}", + target.display() + ) + })?; + if read == 0 { + break; + } + let read = u64::try_from(read)?; + size = size + .checked_add(read) + .ok_or_else(|| anyhow::anyhow!("account archive media size overflow"))?; + total = total + .checked_add(read) + .ok_or_else(|| anyhow::anyhow!("account archive media size overflow"))?; + if size > expanded_limit || total > expanded_limit { + anyhow::bail!("account archive media exceeds the {expanded_limit}-byte limit"); + } + let chunk = &buffer[..usize::try_from(read)?]; + if prefix.len() < MEDIA_SNIFF_PREFIX_BYTES { + let take = (MEDIA_SNIFF_PREFIX_BYTES - prefix.len()).min(chunk.len()); + prefix.extend_from_slice(&chunk[..take]); + } + hasher.update(chunk); + output.write_all(chunk)?; + } + output + .sync_all() + .with_context(|| format!("failed to flush extracted media file {}", target.display()))?; + Ok((size, hex_lower(hasher.finalize().as_ref()), prefix, total)) +} + +// --------------------------------------------------------------------------- +// Import: document validation +// --------------------------------------------------------------------------- + +struct StagedArchive { + manifest: ManifestDocument, + profile: ProfileDocument, + posts: Vec, + media: Vec, + follows: Vec, + muted_words: Vec, +} + +struct ValidatedPost { + archive_id: i64, + text: String, + created_at: String, + edited_at: Option, + parent_id: Option, + root_id: Option, + quote_id: Option, + media_ids: Vec, + embeds: Vec, +} + +struct ValidatedMedia { + id: i64, + original_filename: String, + mime_type: String, + media_kind: String, + byte_len: u64, + alt_text: String, + is_nsfw: bool, + sha256: String, + archive_path: String, + staged_path: PathBuf, +} + +/// Parses and validates every document, applying destination instance limits. +fn parse_archive( + settings: &Settings, + extracted: &ExtractedArchive, + limits: ArchiveLimits, +) -> anyhow::Result { + let document = |name: &str| -> anyhow::Result<&[u8]> { + extracted + .documents + .get(name) + .map(Vec::as_slice) + .ok_or_else(|| anyhow::anyhow!("account archive is missing required document {name}")) + }; + + let manifest: ManifestDocument = parse_document(MANIFEST_PATH, document(MANIFEST_PATH)?)?; + if manifest.format_version != FORMAT_VERSION { + anyhow::bail!( + "unsupported account archive format version {} (this instance supports {FORMAT_VERSION})", + manifest.format_version + ); + } + if manifest.app != APP_NAME { + anyhow::bail!("account archive is not a RustPost account archive"); + } + validate_timestamp_text(&manifest.created_at, "manifest created_at")?; + if manifest.archive_id.is_empty() + || manifest.archive_id.chars().count() > 128 + || manifest.archive_id.chars().any(char::is_control) + { + anyhow::bail!("manifest archive_id is invalid"); + } + if manifest.username.is_empty() + || manifest.username.chars().count() > 128 + || manifest.username.chars().any(char::is_control) + { + anyhow::bail!("manifest username is invalid"); + } + if manifest.entry_count != extracted.entry_count { + anyhow::bail!("manifest entry_count does not match the number of archive entries"); + } + + let mut profile: ProfileDocument = parse_document(PROFILE_PATH, document(PROFILE_PATH)?)?; + if profile.theme != "light" && profile.theme != "dark" { + anyhow::bail!("profile theme must be light or dark"); + } + validate_profile_text(&profile.display_name, &profile.bio, settings) + .with_context(|| "profile contains invalid text")?; + if profile.location.chars().count() > MAX_LOCATION_CHARS + || profile.location.chars().any(char::is_control) + { + anyhow::bail!("profile location is invalid"); + } + let website = profile.website.trim().to_owned(); + if website.chars().count() > MAX_WEBSITE_CHARS || website.chars().any(char::is_control) { + anyhow::bail!("profile website is invalid"); + } + if !(website.is_empty() || website.starts_with("http://") || website.starts_with("https://")) { + anyhow::bail!("profile website must start with http:// or https://"); + } + profile.website = website; + validate_timestamp_text(&profile.created_at, "profile created_at")?; + + let media_documents: Vec = parse_document(MEDIA_PATH, document(MEDIA_PATH)?)?; + let media = validate_media_documents(&media_documents, extracted, limits)?; + let media_ids = media.iter().map(|entry| entry.id).collect::>(); + + let post_documents: Vec = parse_document(POSTS_PATH, document(POSTS_PATH)?)?; + let posts = validate_post_documents(&post_documents, settings, &media_ids)?; + + let follows: Vec = parse_document(FOLLOWS_PATH, document(FOLLOWS_PATH)?)?; + if follows.len() > MAX_FOLLOWS { + anyhow::bail!("follows.json contains more than {MAX_FOLLOWS} entries"); + } + for follow in &follows { + if follow.username.is_empty() + || follow.username.chars().count() > 128 + || follow.username.chars().any(char::is_control) + { + anyhow::bail!("follows.json contains an invalid username"); + } + validate_timestamp_text(&follow.created_at, "follows.json created_at")?; + } + + let settings_document: SettingsDocument = + parse_document(SETTINGS_PATH, document(SETTINGS_PATH)?)?; + if settings_document.muted_words.len() > MAX_MUTED_WORDS { + anyhow::bail!("settings.json contains more than {MAX_MUTED_WORDS} muted words"); + } + let mut muted_words = Vec::with_capacity(settings_document.muted_words.len()); + for word in settings_document.muted_words { + let term = clean_muted_word(&word.term)?; + validate_timestamp_text(&word.created_at, "settings.json muted word created_at")?; + muted_words.push(MutedWordDocument { + term, + created_at: word.created_at, + }); + } + + Ok(StagedArchive { + manifest, + profile, + posts, + media, + follows, + muted_words, + }) +} + +/// `serde_json` applies its default recursion limit (128), so deeply nested +/// documents are rejected instead of risking stack exhaustion. +fn parse_document(name: &str, bytes: &[u8]) -> anyhow::Result { + serde_json::from_slice(bytes).with_context(|| format!("{name} is not a valid JSON document")) +} + +#[expect( + clippy::too_many_lines, + reason = "media documents are validated in two passes over ids, paths, bytes, and sniffed content" +)] +fn validate_media_documents( + documents: &[MediaDocument], + extracted: &ExtractedArchive, + limits: ArchiveLimits, +) -> anyhow::Result> { + if documents.len() > MAX_MEDIA { + anyhow::bail!("media.json contains more than {MAX_MEDIA} media entries"); + } + + let mut ids = BTreeSet::new(); + let mut paths = BTreeSet::new(); + let mut declared_total = 0_u64; + for document in documents { + if document.id < 0 { + anyhow::bail!("media.json contains a negative media id"); + } + if !ids.insert(document.id) { + anyhow::bail!("media.json contains duplicate media id {}", document.id); + } + if document.media_kind != "image" && document.media_kind != "video" { + anyhow::bail!("media {} has an unsupported media kind", document.id); + } + if document.mime_type.is_empty() + || document.mime_type.chars().count() > 255 + || document.mime_type.chars().any(char::is_control) + || !document + .mime_type + .starts_with(&format!("{}/", document.media_kind)) + { + anyhow::bail!( + "media {} mime type does not match its media kind", + document.id + ); + } + if document.sha256.len() != 64 + || !document.sha256.bytes().all(|byte| byte.is_ascii_hexdigit()) + { + anyhow::bail!("media {} sha256 is not 64 hex characters", document.id); + } + if document.original_filename.chars().count() > MAX_ORIGINAL_FILENAME_CHARS { + anyhow::bail!("media {} original filename is too long", document.id); + } + if document.alt_text.chars().count() > MAX_ALT_TEXT_CHARS { + anyhow::bail!("media {} alt text is too long", document.id); + } + if document.conversion_state.chars().count() > MAX_CONVERSION_STATE_CHARS { + anyhow::bail!("media {} conversion state is too long", document.id); + } + if !paths.insert(document.archive_path.clone()) { + anyhow::bail!( + "media.json lists archive path {} more than once", + document.archive_path + ); + } + declared_total = declared_total + .checked_add(document.byte_len) + .ok_or_else(|| anyhow::anyhow!("media.json byte length total overflow"))?; + if declared_total > limits.expanded_bytes { + anyhow::bail!( + "media.json declares more than the {}-byte account archive media limit", + limits.expanded_bytes + ); + } + } + + let mut validated = Vec::with_capacity(documents.len()); + for document in documents { + let Some(extracted_file) = extracted.media_files.get(&document.archive_path) else { + anyhow::bail!( + "media {} archive path {} is missing from the archive", + document.id, + document.archive_path + ); + }; + if extracted_file.size != document.byte_len { + anyhow::bail!( + "media {} byte length does not match the extracted file", + document.id + ); + } + if !extracted_file.sha256.eq_ignore_ascii_case(&document.sha256) { + anyhow::bail!( + "media {} sha256 does not match the extracted file", + document.id + ); + } + // Interactive uploads derive the stored mime type from `infer`; an + // imported file must agree with its declared type so a mislabeled + // payload is never installed as media. + let Some(sniffed) = infer::get(&extracted_file.prefix) else { + anyhow::bail!( + "media {} content is not a recognized media type", + document.id + ); + }; + if !sniffed + .mime_type() + .eq_ignore_ascii_case(&document.mime_type) + { + anyhow::bail!( + "media {} content type {} does not match the declared {}", + document.id, + sniffed.mime_type(), + document.mime_type + ); + } + validated.push(ValidatedMedia { + id: document.id, + original_filename: document.original_filename.clone(), + mime_type: document.mime_type.clone(), + media_kind: document.media_kind.clone(), + byte_len: document.byte_len, + alt_text: document.alt_text.clone(), + is_nsfw: document.is_nsfw, + sha256: document.sha256.to_ascii_lowercase(), + archive_path: document.archive_path.clone(), + staged_path: extracted_file.staged_path.clone(), + }); + } + + if extracted.media_files.len() != documents.len() { + for name in extracted.media_files.keys() { + if !paths.contains(name) { + anyhow::bail!("account archive contains unlisted media file {name}"); + } + } + anyhow::bail!("media.json does not list every media file in the archive"); + } + Ok(validated) +} + +fn validate_post_documents( + documents: &[PostDocument], + settings: &Settings, + media_ids: &BTreeSet, +) -> anyhow::Result> { + if documents.len() > MAX_POSTS { + anyhow::bail!("posts.json contains more than {MAX_POSTS} posts"); + } + let mut ids = BTreeSet::new(); + for document in documents { + if document.id < 0 { + anyhow::bail!("posts.json contains a negative post id"); + } + if !ids.insert(document.id) { + anyhow::bail!("posts.json contains duplicate post id {}", document.id); + } + } + + let mut validated = Vec::with_capacity(documents.len()); + for document in documents { + let text = clean_post_text( + &document.text, + settings.posts.max_text_chars, + document.media_ids.len(), + ) + .with_context(|| format!("post {} text is invalid", document.id))?; + validate_timestamp_text(&document.created_at, "post created_at")?; + if let Some(edited_at) = &document.edited_at { + validate_timestamp_text(edited_at, "post edited_at")?; + } + for media_id in &document.media_ids { + if !media_ids.contains(media_id) { + anyhow::bail!( + "post {} references unknown media id {media_id}", + document.id + ); + } + } + if document.embeds.len() > MAX_EMBEDS_PER_POST { + anyhow::bail!( + "post {} contains more than {MAX_EMBEDS_PER_POST} embeds", + document.id + ); + } + let mut embed_positions = BTreeSet::new(); + let mut embed_videos = BTreeSet::new(); + for embed in &document.embeds { + if embed.provider != "youtube" { + anyhow::bail!( + "post {} contains an unsupported embed provider", + document.id + ); + } + if embed.video_id.is_empty() + || embed.video_id.chars().count() > MAX_EMBED_VIDEO_ID_CHARS + || embed.video_id.chars().any(char::is_control) + { + anyhow::bail!("post {} contains an invalid embed video id", document.id); + } + if embed.position < 0 || !embed_positions.insert(embed.position) { + anyhow::bail!("post {} contains duplicate embed positions", document.id); + } + if !embed_videos.insert(embed.video_id.clone()) { + anyhow::bail!("post {} lists the same embed video twice", document.id); + } + for url in [ + &embed.original_url, + &embed.canonical_url, + &embed.thumbnail_url, + &embed.embed_url, + ] { + if url.is_empty() + || url.chars().count() > MAX_EMBED_URL_CHARS + || url.chars().any(char::is_control) + { + anyhow::bail!("post {} contains an invalid embed URL", document.id); + } + } + if let Some(title) = &embed.title + && (title.chars().count() > MAX_EMBED_TITLE_CHARS + || title.chars().any(char::is_control)) + { + anyhow::bail!("post {} contains an invalid embed title", document.id); + } + } + validated.push(ValidatedPost { + archive_id: document.id, + text, + created_at: document.created_at.clone(), + edited_at: document.edited_at.clone(), + parent_id: document.parent_id, + root_id: document.root_id, + quote_id: document.quote_id, + media_ids: document.media_ids.clone(), + embeds: document.embeds.clone(), + }); + } + Ok(validated) +} + +fn validate_timestamp_text(value: &str, field: &str) -> anyhow::Result<()> { + if value.is_empty() + || value.chars().count() > MAX_TIMESTAMP_CHARS + || value.chars().any(char::is_control) + { + anyhow::bail!("{field} is not a valid timestamp"); + } + Ok(()) +} + +/// Mirrors `social::clean_muted_word` (private) so imports normalize muted +/// words exactly like the interactive path. +fn clean_muted_word(term: &str) -> anyhow::Result { + let trimmed = term.trim(); + if trimmed.is_empty() { + anyhow::bail!("muted word cannot be empty"); + } + if trimmed.chars().count() > 100 { + anyhow::bail!("muted word is too long"); + } + if trimmed + .chars() + .any(|ch| ch.is_control() && !matches!(ch, '\n' | '\r' | '\t')) + { + anyhow::bail!("muted word contains unsupported control characters"); + } + Ok(trimmed.to_owned()) +} + +// --------------------------------------------------------------------------- +// Import: destination checks and media installation +// --------------------------------------------------------------------------- + +/// Rejects deleted and deletion-pending destination accounts. +async fn ensure_import_destination(pool: &SqlitePool, user_id: i64) -> anyhow::Result<()> { + let state = pool + .call(move |conn| { + conn.query_row( + "SELECT is_deleted, deletion_requested_at, deletion_scheduled_at FROM users WHERE id = ?", + [user_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + )) + }, + ) + .optional() + .map_err(Into::into) + }) + .await?; + match state { + None => anyhow::bail!("import destination account does not exist"), + Some((is_deleted, _, _)) if is_deleted != 0 => { + anyhow::bail!("import destination account is deleted") + } + Some((_, requested_at, scheduled_at)) + if requested_at.is_some() || scheduled_at.is_some() => + { + anyhow::bail!("import destination account is pending deletion") + } + Some(_) => Ok(()), + } +} + +async fn archive_already_imported( + pool: &SqlitePool, + user_id: i64, + archive_id: &str, +) -> anyhow::Result { + let archive_id = archive_id.to_owned(); + let existing = pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM account_imports WHERE user_id = ? AND archive_id = ?", + params![user_id, archive_id], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await?; + Ok(existing) +} + +/// One archived media entry's installation decision. +enum MediaInstallDecision { + /// The bytes were copied by us to these destination paths. + Copied { + stored_path: PathBuf, + public_path: String, + }, + /// An existing canonical row already stores these bytes. + Canonical { + canonical_id: i64, + stored_path: String, + public_path: String, + }, +} + +struct InstalledMedia { + decisions: Vec, + installed_paths: Vec, +} + +struct CanonicalMedia { + id: i64, + stored_path: String, + public_path: String, +} + +/// Copies every non-duplicate media entry into the destination upload +/// directories before the database transaction runs. On failure all files +/// copied so far are removed. +async fn install_media_files( + pool: &SqlitePool, + paths: &RuntimePaths, + settings: &Settings, + media: &[ValidatedMedia], +) -> anyhow::Result { + let mut decisions = Vec::with_capacity(media.len()); + let mut installed_paths = Vec::new(); + for entry in media { + match install_one_media(pool, paths, settings, entry).await { + Ok((decision, copied_path)) => { + if let Some(copied_path) = copied_path { + installed_paths.push(copied_path); + } + decisions.push(decision); + } + Err(error) => { + remove_files_best_effort(&installed_paths); + return Err(error); + } + } + } + Ok(InstalledMedia { + decisions, + installed_paths, + }) +} + +async fn install_one_media( + pool: &SqlitePool, + paths: &RuntimePaths, + settings: &Settings, + entry: &ValidatedMedia, +) -> anyhow::Result<(MediaInstallDecision, Option)> { + if let Some(canonical) = find_canonical_media(pool, &entry.media_kind, &entry.sha256).await? { + return Ok(( + MediaInstallDecision::Canonical { + canonical_id: canonical.id, + stored_path: canonical.stored_path, + public_path: canonical.public_path, + }, + None, + )); + } + let directory = media_install_dir(paths, settings, &entry.media_kind, &entry.mime_type)?; + fs::create_dir_all(directory).with_context(|| { + format!( + "failed to create media upload directory {}", + directory.display() + ) + })?; + let extension = import_media_extension(&entry.mime_type, &entry.media_kind)?; + let basename = import_media_basename(&entry.original_filename, &entry.sha256); + let target = claim_media_path(directory, &basename, extension)?; + if let Err(error) = copy_media_bytes(&entry.staged_path, &target, entry.byte_len) { + let _ = fs::remove_file(&target); + return Err(error) + .with_context(|| format!("failed to install imported media {}", entry.archive_path)); + } + let public_path = public_media_path(paths, &target)?; + Ok(( + MediaInstallDecision::Copied { + stored_path: target.clone(), + public_path, + }, + Some(target), + )) +} + +async fn find_canonical_media( + pool: &SqlitePool, + media_kind: &str, + sha256: &str, +) -> anyhow::Result> { + let media_kind = media_kind.to_owned(); + let sha256 = sha256.to_owned(); + pool.call(move |conn| { + conn.query_row( + r#" + SELECT id, stored_path, public_path FROM media + WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? + ORDER BY id ASC LIMIT 1 + "#, + params![media_kind, sha256], + |row| { + Ok(CanonicalMedia { + id: row.get(0)?, + stored_path: row.get(1)?, + public_path: row.get(2)?, + }) + }, + ) + .optional() + .map_err(Into::into) + }) + .await +} + +/// Chooses the upload directory for imported bytes. Types outside the +/// instance's convertible allow-list are stored in `originals` because the +/// server layer would never convert or serve them from images/videos. +fn media_install_dir<'a>( + paths: &'a RuntimePaths, + settings: &Settings, + media_kind: &str, + mime_type: &str, +) -> anyhow::Result<&'a Path> { + match media_kind { + "image" => { + if settings + .media + .allowed_image_mime_types + .iter() + .any(|allowed| allowed == mime_type) + { + Ok(&paths.uploads_images) + } else { + Ok(&paths.uploads_originals) + } + } + "video" => { + if settings + .media + .allowed_video_mime_types + .iter() + .any(|allowed| allowed == mime_type) + { + Ok(&paths.uploads_videos) + } else { + Ok(&paths.uploads_originals) + } + } + _ => anyhow::bail!("media entry has an unsupported media kind"), + } +} + +fn public_media_path(paths: &RuntimePaths, path: &Path) -> anyhow::Result { + let filename = path + .file_name() + .and_then(|name| name.to_str()) + .ok_or_else(|| anyhow::anyhow!("installed media path has no file name"))?; + if path.starts_with(&paths.uploads_images) { + return Ok(format!("/uploads/images/{filename}")); + } + if path.starts_with(&paths.uploads_videos) { + return Ok(format!("/uploads/videos/{filename}")); + } + if path.starts_with(&paths.uploads_originals) { + return Ok(format!("/uploads/originals/{filename}")); + } + anyhow::bail!("installed media path is outside the upload directories") +} + +/// Claims an unused destination file name with `create_new` so concurrent +/// imports can never overwrite each other's files. +fn claim_media_path(directory: &Path, basename: &str, extension: &str) -> anyhow::Result { + let fallback = directory.join(format!( + "{basename}-{}.{extension}", + Uuid::new_v4().simple() + )); + let candidates = [ + directory.join(format!("{basename}.{extension}")), + fallback, + directory.join(format!( + "{basename}-{}.{extension}", + Uuid::new_v4().simple() + )), + ]; + for candidate in candidates { + match OpenOptions::new() + .write(true) + .create_new(true) + .open(&candidate) + { + Ok(_claimed) => return Ok(candidate), + Err(error) if error.kind() == io::ErrorKind::AlreadyExists => {} + Err(error) => { + return Err(error).with_context(|| { + format!("failed to create media file {}", candidate.display()) + }); + } + } + } + anyhow::bail!("failed to claim a unique file name for imported media") +} + +fn copy_media_bytes(source: &Path, target: &Path, expected: u64) -> anyhow::Result<()> { + let mut source_file = File::open(source) + .with_context(|| format!("failed to open staged media file {}", source.display()))?; + let mut target_file = OpenOptions::new() + .write(true) + .truncate(true) + .open(target) + .with_context(|| format!("failed to open media destination {}", target.display()))?; + let copied = io::copy(&mut source_file, &mut target_file)?; + target_file + .sync_all() + .with_context(|| format!("failed to flush media destination {}", target.display()))?; + if copied != expected { + anyhow::bail!("copied media size does not match the archive entry"); + } + Ok(()) +} + +/// Mirrors `media::stable_media_basename` (private): sanitized original stem +/// plus a short content-hash prefix. +fn import_media_basename(original_filename: &str, sha256: &str) -> String { + let stem = Path::new(original_filename) + .file_stem() + .and_then(|stem| stem.to_str()) + .unwrap_or("upload"); + let mut sanitized = String::with_capacity(stem.len().min(64) + 1 + HASH_PREFIX_LEN); + let mut last_was_separator = false; + for character in stem.chars().flat_map(char::to_lowercase) { + let next = if character.is_ascii_alphanumeric() || matches!(character, '-' | '_' | '.') { + Some(character) + } else if character.is_ascii_whitespace() { + Some('-') + } else { + None + }; + let Some(next) = next else { + continue; + }; + if matches!(next, '-' | '_' | '.') { + if sanitized.is_empty() || last_was_separator { + continue; + } + last_was_separator = true; + } else { + last_was_separator = false; + } + sanitized.push(next); + if sanitized.len() >= 64 { + break; + } + } + while sanitized.ends_with(['-', '_', '.']) { + sanitized.pop(); + } + if sanitized.is_empty() { + sanitized.push_str("upload"); + } + let prefix_len = sha256.len().min(HASH_PREFIX_LEN); + format!("{}-{}", sanitized, &sha256[..prefix_len]) +} + +/// Mirrors `media::safe_extension` (private). +fn import_media_extension(mime_type: &str, media_kind: &str) -> anyhow::Result<&'static str> { + match (media_kind, mime_type) { + ("image", "image/jpeg") => Ok("jpg"), + ("image", "image/png") => Ok("png"), + ("image", "image/gif") => Ok("gif"), + ("image", "image/webp") => Ok("webp"), + ("image", _) => Ok("img"), + ("video", "video/webm") => Ok("webm"), + ("video", "video/quicktime") => Ok("mov"), + ("video", _) => Ok("mp4"), + _ => anyhow::bail!("media entry has an unsupported media kind"), + } +} + +fn remove_files_best_effort(paths: &[PathBuf]) { + for path in paths { + if let Err(error) = fs::remove_file(path) + && error.kind() != io::ErrorKind::NotFound + { + tracing::warn!( + path = %path.display(), + error = %error, + "failed to remove imported media file during cleanup" + ); + } + } +} + +// --------------------------------------------------------------------------- +// Import: database transaction +// --------------------------------------------------------------------------- + +struct ImportPlan { + archive_id: String, + archive_username: String, + profile: ProfileDocument, + posts: Vec, + media: Vec, + media_install: Vec, + follows: Vec, + muted_words: Vec, +} + +struct DestinationRow { + display_name: String, + bio: String, + location: String, + website: String, + theme: String, + nsfw_blur_enabled: bool, + liked_posts_public: bool, + follow_approval_required: bool, +} + +/// Applies the import in one transaction. Returns the report plus files that +/// were copied but turned out to be unnecessary duplicates; the caller removes +/// those only after the commit succeeds. +#[expect( + clippy::too_many_lines, + reason = "the import transaction inserts every record family in one auditable block" +)] +async fn apply_import( + pool: &SqlitePool, + user_id: i64, + max_username_len: usize, + plan: ImportPlan, +) -> anyhow::Result<(ImportReport, Vec)> { + pool.call(move |conn| { + let tx = conn.transaction()?; + + // Re-check the destination inside the transaction; concurrent account + // deletion must not race with the import. + let destination = tx + .query_row( + r#" + SELECT is_deleted, deletion_requested_at, deletion_scheduled_at, + display_name, bio, location, website, theme, nsfw_blur_enabled, + liked_posts_public, follow_approval_required + FROM users WHERE id = ? + "#, + [user_id], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, Option>(1)?, + row.get::<_, Option>(2)?, + DestinationRow { + display_name: row.get(3)?, + bio: row.get(4)?, + location: row.get(5)?, + website: row.get(6)?, + theme: row.get(7)?, + nsfw_blur_enabled: row.get::<_, i64>(8)? != 0, + liked_posts_public: row.get::<_, i64>(9)? != 0, + follow_approval_required: row.get::<_, i64>(10)? != 0, + }, + )) + }, + ) + .optional()? + .ok_or_else(|| anyhow::anyhow!("import destination account does not exist"))?; + if destination.0 != 0 { + anyhow::bail!("import destination account is deleted"); + } + if destination.1.is_some() || destination.2.is_some() { + anyhow::bail!("import destination account is pending deletion"); + } + let current = destination.3; + + // Profile: fill only empty text fields, apply preference fields as-is. + let mut profile_fields_applied = 0usize; + let mut profile_fields_skipped = 0usize; + let display_name = fill_empty_field( + ¤t.display_name, + &plan.profile.display_name, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let bio = fill_empty_field( + ¤t.bio, + &plan.profile.bio, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let location = fill_empty_field( + ¤t.location, + &plan.profile.location, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let website = fill_empty_field( + ¤t.website, + &plan.profile.website, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let theme = replace_preference( + ¤t.theme, + &plan.profile.theme, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let nsfw_blur_enabled = replace_preference( + current.nsfw_blur_enabled, + plan.profile.nsfw_blur_enabled, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let liked_posts_public = replace_preference( + current.liked_posts_public, + plan.profile.liked_posts_public, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + let follow_approval_required = replace_preference( + current.follow_approval_required, + plan.profile.follow_approval_required, + &mut profile_fields_applied, + &mut profile_fields_skipped, + ); + tx.execute( + r#" + UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, + theme = ?, nsfw_blur_enabled = ?, liked_posts_public = ?, + follow_approval_required = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + "#, + params![ + display_name, + bio, + location, + website, + theme, + i64::from(nsfw_blur_enabled), + i64::from(liked_posts_public), + i64::from(follow_approval_required), + user_id, + ], + )?; + + // Media rows first so posts can attach remapped media ids. + if plan.media.len() != plan.media_install.len() { + anyhow::bail!("internal import error: media install plan is incomplete"); + } + let mut media_id_map: HashMap = HashMap::with_capacity(plan.media.len()); + let mut unused_copies: Vec = Vec::new(); + for (media, decision) in plan.media.iter().zip(plan.media_install.iter()) { + let (stored_path, public_path, canonical_id) = match decision { + MediaInstallDecision::Copied { + stored_path, + public_path, + } => { + // A canonical row may have appeared since planning. Prefer + // it and leave our copy for post-commit cleanup. + match find_canonical_media_tx(&tx, &media.media_kind, &media.sha256)? { + Some((canonical_id, canonical_stored, canonical_public)) => { + unused_copies.push(stored_path.clone()); + (canonical_stored, canonical_public, Some(canonical_id)) + } + None => ( + stored_path.to_string_lossy().to_string(), + public_path.clone(), + None, + ), + } + } + MediaInstallDecision::Canonical { + canonical_id, + stored_path, + public_path, + } => { + let still_valid = tx + .query_row( + r#" + SELECT 1 FROM media + WHERE id = ? AND canonical_media_id IS NULL + AND media_kind = ? AND original_sha256 = ? + "#, + params![canonical_id, media.media_kind, media.sha256], + |_| Ok(()), + ) + .optional()? + .is_some(); + if !still_valid { + anyhow::bail!( + "media deduplication target disappeared during import" + ); + } + (stored_path.clone(), public_path.clone(), Some(*canonical_id)) + } + }; + tx.execute( + r#" + INSERT INTO media ( + owner_user_id, original_filename, stored_path, public_path, + mime_type, media_kind, byte_len, alt_text, conversion_state, + original_sha256, canonical_media_id, is_nsfw + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'imported', ?, ?, ?) + "#, + params![ + user_id, + media.original_filename, + stored_path, + public_path, + media.mime_type, + media.media_kind, + i64::try_from(media.byte_len)?, + media.alt_text, + media.sha256, + canonical_id, + i64::from(media.is_nsfw), + ], + )?; + media_id_map.insert(media.id, tx.last_insert_rowid()); + } + + // Posts are inserted in two phases: first without references, then an + // UPDATE per row. This avoids ordering problems when a quote points at + // a post with a higher row id while keeping foreign keys valid. + let mut post_id_map: HashMap = HashMap::with_capacity(plan.posts.len()); + for post in &plan.posts { + tx.execute( + "INSERT INTO posts (user_id, text, created_at, edited_at) VALUES (?, ?, ?, ?)", + params![user_id, post.text, post.created_at, post.edited_at], + )?; + post_id_map.insert(post.archive_id, tx.last_insert_rowid()); + } + let mut post_references_dropped = 0usize; + for post in &plan.posts { + let new_id = *post_id_map + .get(&post.archive_id) + .ok_or_else(|| anyhow::anyhow!("internal import error: post id missing"))?; + let parent_id = post.parent_id.and_then(|id| post_id_map.get(&id).copied()); + let root_id = post.root_id.and_then(|id| post_id_map.get(&id).copied()); + let quote_id = post.quote_id.and_then(|id| post_id_map.get(&id).copied()); + post_references_dropped += usize::from(post.parent_id.is_some() && parent_id.is_none()) + + usize::from(post.root_id.is_some() && root_id.is_none()) + + usize::from(post.quote_id.is_some() && quote_id.is_none()); + if parent_id.is_some() || root_id.is_some() || quote_id.is_some() { + tx.execute( + "UPDATE posts SET parent_post_id = ?, root_post_id = ?, quote_post_id = ? WHERE id = ?", + params![parent_id, root_id, quote_id, new_id], + )?; + } + for (position, media_id) in post.media_ids.iter().enumerate() { + let new_media_id = media_id_map.get(media_id).copied().ok_or_else(|| { + anyhow::anyhow!("internal import error: media id missing") + })?; + tx.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, ?)", + params![new_id, new_media_id, i64::try_from(position)?], + )?; + } + for embed in &post.embeds { + tx.execute( + r#" + INSERT INTO post_embeds ( + post_id, provider, video_id, original_url, canonical_url, title, + thumbnail_url, embed_url, position, fetched_at + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, CASE WHEN ? IS NULL THEN NULL ELSE CURRENT_TIMESTAMP END) + "#, + params![ + new_id, + embed.provider, + embed.video_id, + embed.original_url, + embed.canonical_url, + embed.title, + embed.thumbnail_url, + embed.embed_url, + embed.position, + embed.title, + ], + )?; + } + } + + // Follows resolve against the destination instance; protected targets + // always use follow_requests and never bypass approval. + let mut follows_imported = 0usize; + let mut follows_pending = 0usize; + let mut follows_skipped = 0usize; + for follow in &plan.follows { + let Ok(normalized) = normalize_username(&follow.username, max_username_len) else { + follows_skipped += 1; + continue; + }; + let target = tx + .query_row( + "SELECT id, is_deleted, is_suspended, follow_approval_required FROM users WHERE normalized_username = ?", + [normalized], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, i64>(2)?, + row.get::<_, i64>(3)?, + )) + }, + ) + .optional()?; + let Some((target_id, is_deleted, is_suspended, approval_required)) = target else { + follows_skipped += 1; + continue; + }; + if target_id == user_id || is_deleted != 0 || is_suspended != 0 { + follows_skipped += 1; + continue; + } + if is_blocked_tx(&tx, user_id, target_id)? { + follows_skipped += 1; + continue; + } + if approval_required != 0 { + let already_following = tx + .query_row( + "SELECT 1 FROM follows WHERE follower_id = ? AND followed_id = ?", + params![user_id, target_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if already_following { + follows_skipped += 1; + continue; + } + let inserted = tx.execute( + "INSERT OR IGNORE INTO follow_requests (requester_id, target_id, created_at) VALUES (?, ?, ?)", + params![user_id, target_id, follow.created_at], + )?; + if inserted > 0 { + create_notification_tx( + &tx, + target_id, + user_id, + "follow_request", + FOLLOW_REQUEST_NOTIFICATION_MESSAGE, + )?; + follows_pending += 1; + } else { + follows_skipped += 1; + } + } else { + let inserted = tx.execute( + "INSERT OR IGNORE INTO follows (follower_id, followed_id, created_at) VALUES (?, ?, ?)", + params![user_id, target_id, follow.created_at], + )?; + if inserted > 0 { + create_notification_tx( + &tx, + target_id, + user_id, + "follow", + FOLLOW_NOTIFICATION_MESSAGE, + )?; + follows_imported += 1; + } else { + follows_skipped += 1; + } + } + } + + // Muted words: duplicates are silently skipped by the unique index. + let mut muted_words_imported = 0usize; + for word in &plan.muted_words { + let normalized = word.term.to_lowercase(); + let inserted = tx.execute( + "INSERT OR IGNORE INTO muted_words (user_id, term, normalized_term, created_at) VALUES (?, ?, ?, ?)", + params![user_id, word.term, normalized, word.created_at], + )?; + if inserted > 0 { + muted_words_imported += 1; + } + } + + let report = ImportReport { + archive_id: plan.archive_id.clone(), + archive_username: plan.archive_username.clone(), + posts_imported: plan.posts.len(), + media_imported: plan.media.len(), + follows_imported, + follows_pending, + follows_skipped, + post_references_dropped, + muted_words_imported, + profile_fields_applied, + profile_fields_skipped, + }; + // The UNIQUE(user_id, archive_id) constraint makes concurrent double + // imports fail here, inside the transaction, and roll everything back. + let insert_result = tx.execute( + r#" + INSERT INTO account_imports ( + user_id, archive_id, format_version, posts_imported, media_imported, + follows_imported, follows_pending, follows_skipped + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + "#, + params![ + user_id, + plan.archive_id, + i64::from(FORMAT_VERSION), + i64::try_from(report.posts_imported)?, + i64::try_from(report.media_imported)?, + i64::try_from(report.follows_imported)?, + i64::try_from(report.follows_pending)?, + i64::try_from(report.follows_skipped)?, + ], + ); + match insert_result { + Ok(_) => {} + Err(rusqlite::Error::SqliteFailure(error, _message)) + if error.code == rusqlite::ErrorCode::ConstraintViolation + && matches!(error.extended_code, SQLITE_CONSTRAINT_UNIQUE | SQLITE_CONSTRAINT_PRIMARYKEY) => + { + anyhow::bail!(ALREADY_IMPORTED_MESSAGE); + } + Err(error) => return Err(error.into()), + } + + tx.commit()?; + Ok((report, unused_copies)) + }) + .await +} + +fn fill_empty_field( + current: &str, + archived: &str, + applied: &mut usize, + skipped: &mut usize, +) -> String { + if current.is_empty() && !archived.is_empty() { + *applied += 1; + archived.to_owned() + } else { + *skipped += 1; + current.to_owned() + } +} + +fn replace_preference( + current: T, + archived: T, + applied: &mut usize, + skipped: &mut usize, +) -> T { + if current == archived { + *skipped += 1; + current + } else { + *applied += 1; + archived + } +} + +fn find_canonical_media_tx( + tx: &rusqlite::Transaction<'_>, + media_kind: &str, + sha256: &str, +) -> anyhow::Result> { + let canonical = tx + .query_row( + r#" + SELECT id, stored_path, public_path FROM media + WHERE canonical_media_id IS NULL AND media_kind = ? AND original_sha256 = ? + ORDER BY id ASC LIMIT 1 + "#, + params![media_kind, sha256], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + )) + }, + ) + .optional()?; + Ok(canonical) +} + +fn is_blocked_tx(tx: &rusqlite::Transaction<'_>, left: i64, right: i64) -> anyhow::Result { + let blocked = tx + .query_row( + r#" + SELECT 1 FROM blocks + WHERE (blocker_id = ? AND blocked_id = ?) OR (blocker_id = ? AND blocked_id = ?) + LIMIT 1 + "#, + params![left, right, right, left], + |_| Ok(()), + ) + .optional()? + .is_some(); + Ok(blocked) +} + +/// Mirrors `social::create_notification_tx` (private): one notification per +/// (actor, kind) pair, and never a self-notification. +fn create_notification_tx( + tx: &rusqlite::Transaction<'_>, + user_id: i64, + actor_id: i64, + kind: &str, + message: &str, +) -> anyhow::Result<()> { + if user_id == actor_id { + return Ok(()); + } + let exists = tx + .query_row( + r#" + SELECT 1 FROM notifications + WHERE user_id = ? AND kind = ? AND actor_user_id = ? AND post_id IS NULL + LIMIT 1 + "#, + params![user_id, kind, actor_id], + |_| Ok(()), + ) + .optional()? + .is_some(); + if exists { + return Ok(()); + } + tx.execute( + "INSERT INTO notifications (user_id, actor_user_id, post_id, kind, message) VALUES (?, ?, NULL, ?, ?)", + params![user_id, actor_id, kind, message], + )?; + Ok(()) +} + +fn hex_lower(bytes: &[u8]) -> String { + const HEX: &[u8; 16] = b"0123456789abcdef"; + let mut encoded = String::with_capacity(bytes.len() * 2); + for &byte in bytes { + encoded.push(char::from(HEX[usize::from(byte >> 4)])); + encoded.push(char::from(HEX[usize::from(byte & 0x0f)])); + } + encoded +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{auth, config::Settings, db}; + + struct TestInstance { + temp: tempfile::TempDir, + paths: RuntimePaths, + pool: SqlitePool, + settings: Settings, + } + + async fn test_instance() -> TestInstance { + 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"); + TestInstance { + temp, + paths, + pool, + settings: Settings::default(), + } + } + + fn sha256_hex(bytes: &[u8]) -> String { + let mut hasher = Sha256::new(); + hasher.update(bytes); + let digest = hasher.finalize(); + let mut encoded = String::with_capacity(digest.len() * 2); + for byte in digest { + encoded.push_str(&format!("{byte:02x}")); + } + encoded + } + + fn document_bytes(value: &T) -> Vec { + serde_json::to_vec(value).expect("document json") + } + + fn build_archive(path: &Path, entries: &[(String, Vec)]) { + let typed = entries + .iter() + .map(|(name, bytes)| (name.as_str(), EntryType::Regular, bytes.as_slice())) + .collect::>(); + write_archive_entries(path, &typed); + } + + /// Builds a gzip tar archive from explicit entry types. Names up to 100 + /// bytes are written verbatim so adversarial names survive unmodified; + /// longer names go through tar-rs's GNU long-name extension. + fn build_typed_archive(path: &Path, entries: &[(String, EntryType, Vec)]) { + let typed = entries + .iter() + .map(|(name, entry_type, bytes)| (name.as_str(), *entry_type, bytes.as_slice())) + .collect::>(); + write_archive_entries(path, &typed); + } + + fn write_archive_entries(path: &Path, entries: &[(&str, EntryType, &[u8])]) { + let file = File::create(path).expect("archive file"); + let encoder = GzEncoder::new(file, Compression::default()); + let mut builder = Builder::new(encoder); + for (name, entry_type, bytes) in entries { + let mut header = Header::new_gnu(); + header.set_entry_type(*entry_type); + header.set_size(u64::try_from(bytes.len()).expect("entry size")); + header.set_mode(0o600); + if name.len() <= 100 { + let name_bytes = name.as_bytes(); + header.as_gnu_mut().expect("gnu header").name[..name_bytes.len()] + .copy_from_slice(name_bytes); + header.set_cksum(); + builder.append(&header, *bytes).expect("append entry"); + } else { + builder + .append_data(&mut header, name, *bytes) + .expect("append long entry"); + } + } + let encoder = builder.into_inner().expect("tar finish"); + encoder.finish().expect("gzip finish"); + } + + /// Minimal PNG-magic bytes; enough for `infer` to accept the content. + fn png_bytes(payload: &[u8]) -> Vec { + let mut bytes = b"\x89PNG\r\n\x1a\n".to_vec(); + bytes.extend_from_slice(payload); + bytes + } + + /// Minimal JPEG-magic bytes; enough for `infer` to accept the content. + fn jpeg_bytes(payload: &[u8]) -> Vec { + let mut bytes = b"\xff\xd8\xff\xe0".to_vec(); + bytes.extend_from_slice(payload); + bytes + } + + /// A raw tar header with the name written verbatim into the GNU name + /// field, bypassing tar-rs path normalization that would strip `.` + /// components or reject unsafe names. + fn raw_header(name: &str, entry_type: EntryType, size: u64) -> Header { + let mut header = Header::new_gnu(); + let name_bytes = name.as_bytes(); + assert!(name_bytes.len() <= 100, "raw tar names must fit one field"); + header.as_gnu_mut().expect("gnu header").name[..name_bytes.len()] + .copy_from_slice(name_bytes); + header.set_entry_type(entry_type); + header.set_size(size); + header.set_mode(0o600); + header.set_cksum(); + header + } + + fn push_raw_entry(tar_bytes: &mut Vec, name: &str, entry_type: EntryType, data: &[u8]) { + push_raw_header( + tar_bytes, + name, + entry_type, + u64::try_from(data.len()).expect("entry size"), + ); + tar_bytes.extend_from_slice(data); + let padding = (512 - data.len() % 512) % 512; + tar_bytes.extend(std::iter::repeat_n(0_u8, padding)); + } + + fn push_raw_header(tar_bytes: &mut Vec, name: &str, entry_type: EntryType, size: u64) { + tar_bytes.extend_from_slice(raw_header(name, entry_type, size).as_bytes()); + } + + fn write_gzip(path: &Path, bytes: &[u8]) { + let file = File::create(path).expect("archive file"); + let mut encoder = GzEncoder::new(file, Compression::default()); + encoder.write_all(bytes).expect("gzip write"); + encoder.finish().expect("gzip finish"); + } + + fn manifest_document(entry_count: usize) -> ManifestDocument { + ManifestDocument { + format_version: FORMAT_VERSION, + app: APP_NAME.to_owned(), + created_at: "2026-01-01T00:00:00Z".to_owned(), + archive_id: Uuid::new_v4().to_string(), + username: "alice".to_owned(), + entry_count, + } + } + + fn valid_profile_document() -> ProfileDocument { + ProfileDocument { + display_name: "Archived".to_owned(), + bio: "archived bio".to_owned(), + location: "Somewhere".to_owned(), + website: "https://example.com".to_owned(), + theme: "dark".to_owned(), + nsfw_blur_enabled: false, + liked_posts_public: false, + follow_approval_required: false, + created_at: "2026-01-01 00:00:00".to_owned(), + } + } + + fn standard_documents() -> Vec<(String, Vec)> { + vec![ + ( + PROFILE_PATH.to_owned(), + document_bytes(&valid_profile_document()), + ), + ( + POSTS_PATH.to_owned(), + document_bytes(&Vec::::new()), + ), + ( + MEDIA_PATH.to_owned(), + document_bytes(&Vec::::new()), + ), + ( + FOLLOWS_PATH.to_owned(), + document_bytes(&Vec::::new()), + ), + ( + SETTINGS_PATH.to_owned(), + document_bytes(&SettingsDocument { + muted_words: Vec::new(), + }), + ), + ] + } + + fn replace_document(documents: &mut [(String, Vec)], name: &str, bytes: Vec) { + let entry = documents + .iter_mut() + .find(|(entry_name, _)| entry_name == name) + .expect("document exists"); + entry.1 = bytes; + } + + fn write_test_archive(path: &Path, documents: Vec<(String, Vec)>, format_version: u32) { + let mut manifest = manifest_document(documents.len() + 1); + manifest.format_version = format_version; + let mut entries = vec![(MANIFEST_PATH.to_owned(), document_bytes(&manifest))]; + entries.extend(documents); + build_archive(path, &entries); + } + + /// Writes an archive whose documents may use non-regular entry types. The + /// manifest's `entry_count` always covers every entry written. + fn write_typed_test_archive( + path: &Path, + entries: Vec<(String, EntryType, Vec)>, + format_version: u32, + ) { + let mut manifest = manifest_document(entries.len() + 1); + manifest.format_version = format_version; + let mut all = vec![( + MANIFEST_PATH.to_owned(), + EntryType::Regular, + document_bytes(&manifest), + )]; + all.extend(entries); + build_typed_archive(path, &all); + } + + fn test_media_document(byte_len: u64, sha256: String) -> MediaDocument { + media_document(0, "media/0.png", byte_len, sha256) + } + + fn media_document(id: i64, archive_path: &str, byte_len: u64, sha256: String) -> MediaDocument { + MediaDocument { + id, + original_filename: "photo.png".to_owned(), + mime_type: "image/png".to_owned(), + media_kind: "image".to_owned(), + byte_len, + alt_text: String::new(), + is_nsfw: false, + conversion_state: "original".to_owned(), + sha256, + archive_path: archive_path.to_owned(), + } + } + + async fn register_user(instance: &TestInstance, username: &str) -> i64 { + auth::register_user( + &instance.pool, + &instance.settings, + username, + "very secure password", + false, + ) + .await + .expect("registered user") + } + + /// Limits an operator could not configure through validation, used to make + /// overflow and boundary cases reachable without huge fixtures. + fn adversarial_settings(expanded_bytes: u64, entries: usize) -> Settings { + let mut settings = Settings::default(); + settings.accounts.max_archive_expanded_bytes = expanded_bytes; + settings.accounts.max_archive_entries = entries; + settings + } + + #[derive(Debug, PartialEq, Eq)] + struct DestinationSnapshot { + posts: i64, + media: i64, + post_media: i64, + follows: i64, + follow_requests: i64, + muted_words: i64, + notifications: i64, + account_imports: i64, + upload_files: Vec, + tmp_entries: Vec, + } + + async fn snapshot_instance(instance: &TestInstance, user_id: i64) -> DestinationSnapshot { + let counts = instance + .pool + .call(move |conn| { + let count = |sql: &str| -> rusqlite::Result { + conn.query_row(sql, [user_id], |row| row.get(0)) + }; + Ok(( + count("SELECT COUNT(*) FROM posts WHERE user_id = ?")?, + count("SELECT COUNT(*) FROM media WHERE owner_user_id = ?")?, + count( + "SELECT COUNT(*) FROM post_media pm JOIN posts p ON p.id = pm.post_id WHERE p.user_id = ?", + )?, + count("SELECT COUNT(*) FROM follows WHERE follower_id = ?")?, + count("SELECT COUNT(*) FROM follow_requests WHERE requester_id = ?")?, + count("SELECT COUNT(*) FROM muted_words WHERE user_id = ?")?, + count("SELECT COUNT(*) FROM notifications WHERE user_id = ?")?, + count("SELECT COUNT(*) FROM account_imports WHERE user_id = ?")?, + )) + }) + .await + .expect("snapshot counts"); + DestinationSnapshot { + posts: counts.0, + media: counts.1, + post_media: counts.2, + follows: counts.3, + follow_requests: counts.4, + muted_words: counts.5, + notifications: counts.6, + account_imports: counts.7, + upload_files: upload_listing(&instance.paths), + tmp_entries: directory_listing(&instance.paths.tmp_dir), + } + } + + fn upload_listing(paths: &RuntimePaths) -> Vec { + let mut files = Vec::new(); + for (label, root) in [ + ("originals", &paths.uploads_originals), + ("images", &paths.uploads_images), + ("videos", &paths.uploads_videos), + ("thumbs", &paths.uploads_thumbs), + ] { + collect_files(root, label, &mut files); + } + files.sort(); + files + } + + fn collect_files(root: &Path, prefix: &str, out: &mut Vec) { + let Ok(entries) = fs::read_dir(root) else { + return; + }; + for entry in entries.flatten() { + let name = entry.file_name().to_string_lossy().into_owned(); + let path = entry.path(); + if path.is_dir() { + collect_files(&path, &format!("{prefix}/{name}"), out); + } else { + let size = fs::symlink_metadata(&path).map_or(0, |metadata| metadata.len()); + out.push(format!("{prefix}/{name}:{size}")); + } + } + } + + fn directory_listing(dir: &Path) -> Vec { + let mut names = fs::read_dir(dir) + .map(|entries| { + entries + .flatten() + .map(|entry| entry.file_name().to_string_lossy().into_owned()) + .collect::>() + }) + .unwrap_or_default(); + names.sort(); + names + } + + /// Runs one import that must fail, asserting the user-facing message and + /// that the destination database, upload directories, and staging + /// directory are byte-for-byte unchanged. + async fn import_rejected( + instance: &TestInstance, + settings: &Settings, + user_id: i64, + archive_path: &Path, + expected: &str, + ) -> String { + let before = snapshot_instance(instance, user_id).await; + let error = import_account( + &instance.pool, + &instance.paths, + settings, + user_id, + archive_path, + ) + .await + .expect_err("archive must be rejected"); + let message = format!("{error:#}"); + assert!( + message.contains(expected), + "expected {expected:?} in rejection message {message:?}" + ); + let after = snapshot_instance(instance, user_id).await; + assert_eq!( + before, after, + "a rejected import must not mutate the destination" + ); + assert!( + after + .tmp_entries + .iter() + .all(|name| !name.starts_with(IMPORT_TMP_PREFIX)), + "import staging entries left behind: {:?}", + after.tmp_entries + ); + message + } + + #[tokio::test] + #[expect( + clippy::too_many_lines, + reason = "one end-to-end scenario covering export, import, conflicts, and repeat import" + )] + async fn export_import_roundtrip_between_instances() { + let source = test_instance().await; + let destination = test_instance().await; + let source_settings = source.settings.clone(); + let destination_settings = destination.settings.clone(); + + let alice = auth::register_user( + &source.pool, + &source_settings, + "alice", + "very secure password", + false, + ) + .await + .expect("alice"); + let carol = auth::register_user( + &source.pool, + &source_settings, + "carol", + "very secure password", + false, + ) + .await + .expect("carol"); + let dave = auth::register_user( + &source.pool, + &source_settings, + "dave", + "very secure password", + false, + ) + .await + .expect("dave"); + let ghost = auth::register_user( + &source.pool, + &source_settings, + "ghost", + "very secure password", + false, + ) + .await + .expect("ghost"); + let blocked_user = auth::register_user( + &source.pool, + &source_settings, + "blocked_user", + "very secure password", + false, + ) + .await + .expect("blocked_user"); + let suspended_user = auth::register_user( + &source.pool, + &source_settings, + "suspended_user", + "very secure password", + false, + ) + .await + .expect("suspended_user"); + let source_bob = auth::register_user( + &source.pool, + &source_settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + + source + .pool + .call(move |conn| { + conn.execute( + r#" + UPDATE users SET display_name = 'Alice', bio = 'alice bio', + location = 'Berlin', website = 'https://example.com', + theme = 'dark', nsfw_blur_enabled = 0, + liked_posts_public = 0, follow_approval_required = 1 + WHERE id = ? + "#, + [alice], + )?; + Ok(()) + }) + .await + .expect("profile"); + source + .pool + .call(move |conn| { + for target in [ + carol, + dave, + ghost, + blocked_user, + suspended_user, + source_bob, + ] { + conn.execute( + "INSERT INTO follows (follower_id, followed_id, created_at) VALUES (?, ?, '2026-01-01 00:00:00')", + params![alice, target], + )?; + } + Ok(()) + }) + .await + .expect("follows"); + source + .pool + .call(move |conn| { + conn.execute( + "INSERT INTO muted_words (user_id, term, normalized_term, created_at) VALUES (?, 'Spoiler', 'spoiler', '2026-01-01 00:00:00')", + [alice], + )?; + conn.execute( + "INSERT INTO muted_words (user_id, term, normalized_term, created_at) VALUES (?, 'politics', 'politics', '2026-01-02 00:00:00')", + [alice], + )?; + Ok(()) + }) + .await + .expect("muted words"); + + let media_bytes = png_bytes(b"alice media bytes"); + let media_path = source.paths.uploads_images.join("alice-photo.png"); + fs::write(&media_path, &media_bytes).expect("media file"); + let media_path_string = media_path.to_string_lossy().to_string(); + let media_sha = sha256_hex(&media_bytes); + let media_len = i64::try_from(media_bytes.len()).expect("media len"); + let (_source_post1, _source_post2) = source + .pool + .call(move |conn| { + conn.execute( + r#" + INSERT INTO media ( + owner_user_id, original_filename, stored_path, public_path, + mime_type, media_kind, byte_len, alt_text, conversion_state, + original_sha256, is_nsfw + ) + VALUES (?, 'alice-photo.png', ?, '/uploads/images/alice-photo.png', 'image/png', 'image', ?, 'alt text', 'original', ?, 1) + "#, + params![alice, media_path_string, media_len, media_sha], + )?; + let media_id = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO posts (user_id, text, created_at) VALUES (?, 'alice first post', '2026-01-02 03:04:05')", + [alice], + )?; + let post1 = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO post_media (post_id, media_id, position) VALUES (?, ?, 0)", + params![post1, media_id], + )?; + conn.execute( + r#" + INSERT INTO post_embeds ( + post_id, provider, video_id, original_url, canonical_url, title, + thumbnail_url, embed_url, position, fetched_at + ) + VALUES (?, 'youtube', 'dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'https://www.youtube.com/watch?v=dQw4w9WgXcQ', 'A video', 'https://i.ytimg.com/vi/dQw4w9WgXcQ/hqdefault.jpg', 'https://www.youtube.com/embed/dQw4w9WgXcQ', 0, CURRENT_TIMESTAMP) + "#, + [post1], + )?; + conn.execute( + "INSERT INTO posts (user_id, text, parent_post_id, root_post_id, created_at) VALUES (?, 'alice reply', ?, ?, '2026-01-02 03:05:06')", + params![alice, post1, post1], + )?; + let post2 = conn.last_insert_rowid(); + Ok((post1, post2)) + }) + .await + .expect("posts"); + + let export_dir = source.temp.path().join("exports"); + fs::create_dir_all(&export_dir).expect("export dir"); + let archive_path = export_dir.join("alice-account.tar.gz"); + let export = export_account( + &source.pool, + &source.paths, + alice, + &archive_path, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect("export"); + assert_eq!(export.posts, 2); + assert_eq!(export.media, 1); + assert_eq!(export.follows, 6); + assert_eq!(export.muted_words, 2); + assert!(export.bytes > 0); + assert!(archive_path.is_file()); + + let traversal_destination = export_dir.join("..").join("escape.tar.gz"); + let error = export_account( + &source.pool, + &source.paths, + alice, + &traversal_destination, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect_err("traversal destination"); + assert!(error.to_string().contains(".."), "{error}"); + let missing_parent = export_dir.join("missing").join("out.tar.gz"); + let error = export_account( + &source.pool, + &source.paths, + alice, + &missing_parent, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect_err("missing parent"); + assert!(error.to_string().contains("does not exist"), "{error}"); + + let bob = auth::register_user( + &destination.pool, + &destination_settings, + "bob", + "very secure password", + false, + ) + .await + .expect("bob"); + let carol_dest = auth::register_user( + &destination.pool, + &destination_settings, + "carol", + "very secure password", + false, + ) + .await + .expect("carol destination"); + let dave_dest = auth::register_user( + &destination.pool, + &destination_settings, + "dave", + "very secure password", + false, + ) + .await + .expect("dave destination"); + let blocked_dest = auth::register_user( + &destination.pool, + &destination_settings, + "blocked_user", + "very secure password", + false, + ) + .await + .expect("blocked destination"); + let suspended_dest = auth::register_user( + &destination.pool, + &destination_settings, + "suspended_user", + "very secure password", + false, + ) + .await + .expect("suspended destination"); + destination + .pool + .call(move |conn| { + conn.execute( + "UPDATE users SET follow_approval_required = 1 WHERE id = ?", + [dave_dest], + )?; + conn.execute( + "UPDATE users SET is_suspended = 1 WHERE id = ?", + [suspended_dest], + )?; + conn.execute( + "INSERT INTO blocks (blocker_id, blocked_id) VALUES (?, ?)", + params![blocked_dest, bob], + )?; + conn.execute( + "INSERT INTO muted_words (user_id, term, normalized_term) VALUES (?, 'Spoiler', 'spoiler')", + [bob], + )?; + Ok(()) + }) + .await + .expect("destination setup"); + + let report = import_account( + &destination.pool, + &destination.paths, + &destination_settings, + bob, + &archive_path, + ) + .await + .expect("import"); + assert_eq!(report.archive_username, "alice"); + assert_eq!(report.posts_imported, 2); + assert_eq!(report.media_imported, 1); + assert_eq!(report.follows_imported, 1); + assert_eq!(report.follows_pending, 1); + assert_eq!(report.follows_skipped, 4); + assert_eq!(report.muted_words_imported, 1); + assert_eq!(report.profile_fields_applied, 7); + assert_eq!(report.profile_fields_skipped, 1); + + let account = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + r#" + SELECT username, is_admin, theme, nsfw_blur_enabled, liked_posts_public, + follow_approval_required, display_name, bio, location, website + FROM users WHERE id = ? + "#, + [bob], + |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, i64>(4)?, + row.get::<_, i64>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, String>(9)?, + )) + }, + )?) + }) + .await + .expect("account"); + assert_eq!(account.0, "bob"); + assert_eq!(account.1, 0); + assert_eq!(account.2, "dark"); + assert_eq!(account.3, 0); + assert_eq!(account.4, 0); + assert_eq!(account.5, 1); + assert_eq!(account.6, "bob"); + assert_eq!(account.7, "alice bio"); + assert_eq!(account.8, "Berlin"); + assert_eq!(account.9, "https://example.com"); + assert!( + auth::verify_user_password(&destination.pool, bob, "very secure password") + .await + .expect("password check") + ); + let sessions: i64 = destination + .pool + .call(|conn| Ok(conn.query_row("SELECT COUNT(*) FROM sessions", [], |row| row.get(0))?)) + .await + .expect("sessions"); + assert_eq!(sessions, 0); + + let posts = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT id, text, parent_post_id, root_post_id, created_at FROM posts WHERE user_id = ? ORDER BY id ASC", + )?; + let rows = statement + .query_map([bob], |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, String>(4)?, + )) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("posts"); + assert_eq!(posts.len(), 2); + assert_eq!(posts[0].1, "alice first post"); + assert_eq!(posts[1].1, "alice reply"); + assert_eq!(posts[0].4, "2026-01-02 03:04:05"); + assert_eq!(posts[1].2, Some(posts[0].0)); + assert_eq!(posts[1].3, Some(posts[0].0)); + + let media = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + r#" + SELECT owner_user_id, stored_path, public_path, byte_len, conversion_state, + original_sha256, mime_type, media_kind, alt_text, is_nsfw, original_filename + FROM media WHERE owner_user_id = ? + "#, + [bob], + |row| { + Ok(( + row.get::<_, i64>(0)?, + row.get::<_, String>(1)?, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)?, + row.get::<_, String>(4)?, + row.get::<_, String>(5)?, + row.get::<_, String>(6)?, + row.get::<_, String>(7)?, + row.get::<_, String>(8)?, + row.get::<_, i64>(9)?, + row.get::<_, String>(10)?, + )) + }, + )?) + }) + .await + .expect("media"); + assert_eq!(media.0, bob); + assert_eq!(media.3, media_len); + assert_eq!(media.4, "imported"); + assert_eq!(media.5, sha256_hex(&media_bytes)); + assert_eq!(media.6, "image/png"); + assert_eq!(media.7, "image"); + assert_eq!(media.8, "alt text"); + assert_eq!(media.9, 1); + assert_eq!(media.10, "alice-photo.png"); + assert!(Path::new(&media.1).starts_with(&destination.paths.uploads_images)); + assert!(media.2.starts_with("/uploads/images/")); + assert_eq!( + fs::read(&media.1).expect("imported media bytes"), + media_bytes + ); + + let media_links: i64 = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM post_media pm JOIN posts p ON p.id = pm.post_id WHERE p.user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("post media"); + assert_eq!(media_links, 1); + let embed_video: String = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT pe.video_id FROM post_embeds pe JOIN posts p ON p.id = pe.post_id WHERE p.user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("embed"); + assert_eq!(embed_video, "dQw4w9WgXcQ"); + + let follows = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT followed_id FROM follows WHERE follower_id = ? ORDER BY followed_id", + )?; + let rows = statement + .query_map([bob], |row| row.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("follows"); + assert_eq!(follows, vec![carol_dest]); + let pending = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT target_id FROM follow_requests WHERE requester_id = ? ORDER BY target_id", + )?; + let rows = statement + .query_map([bob], |row| row.get::<_, i64>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("pending requests"); + assert_eq!(pending, vec![dave_dest]); + let notifications = destination + .pool + .call(move |conn| { + let mut statement = conn.prepare( + "SELECT user_id, kind FROM notifications WHERE actor_user_id = ? ORDER BY user_id", + )?; + let rows = statement + .query_map([bob], |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, String>(1)?)) + })? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("notifications"); + assert!(notifications.contains(&(carol_dest, "follow".to_owned()))); + assert!(notifications.contains(&(dave_dest, "follow_request".to_owned()))); + assert_eq!(notifications.len(), 2); + let muted_terms = destination + .pool + .call(move |conn| { + let mut statement = + conn.prepare("SELECT term FROM muted_words WHERE user_id = ? ORDER BY term")?; + let rows = statement + .query_map([bob], |row| row.get::<_, String>(0))? + .collect::, _>>()?; + Ok(rows) + }) + .await + .expect("muted words"); + assert_eq!( + muted_terms, + vec!["Spoiler".to_owned(), "politics".to_owned()] + ); + + // A second export has a new archive id, so importing it into another + // destination account is allowed and must reuse the canonical media + // row instead of copying the bytes again. + let dedupe_archive = export_dir.join("alice-account-dedupe.tar.gz"); + export_account( + &source.pool, + &source.paths, + alice, + &dedupe_archive, + ArchiveLimits::from_settings(&source_settings), + ) + .await + .expect("dedupe export"); + let dedupe_report = import_account( + &destination.pool, + &destination.paths, + &destination_settings, + carol_dest, + &dedupe_archive, + ) + .await + .expect("dedupe import"); + assert_eq!(dedupe_report.media_imported, 1); + let duplicate_media = destination + .pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT canonical_media_id, stored_path FROM media WHERE owner_user_id = ?", + [carol_dest], + |row| Ok((row.get::<_, Option>(0)?, row.get::<_, String>(1)?)), + ) + .optional()?) + }) + .await + .expect("duplicate media"); + let (canonical_media_id, duplicate_stored_path) = + duplicate_media.expect("duplicate media row"); + assert!(canonical_media_id.is_some()); + assert_eq!(duplicate_stored_path, media.1); + let image_files = fs::read_dir(&destination.paths.uploads_images) + .expect("uploads dir") + .count(); + assert_eq!(image_files, 1); + + let error = import_account( + &destination.pool, + &destination.paths, + &destination_settings, + bob, + &archive_path, + ) + .await + .expect_err("double import"); + assert!( + error.to_string().contains("already been imported"), + "{error}" + ); + let posts_after: i64 = destination + .pool + .call(move |conn| { + Ok(conn.query_row( + "SELECT COUNT(*) FROM posts WHERE user_id = ?", + [bob], + |row| row.get(0), + )?) + }) + .await + .expect("posts after"); + assert_eq!(posts_after, 2); + } + + #[tokio::test] + async fn import_rejects_invalid_archives() { + let instance = test_instance().await; + let user = register_user(&instance, "importer").await; + let settings = instance.settings.clone(); + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let path = archive_dir.join("wrong-version.tar.gz"); + write_test_archive(&path, standard_documents(), 2); + import_rejected(&instance, &settings, user, &path, "format version").await; + + let path = archive_dir.join("traversal.tar.gz"); + let mut documents = standard_documents(); + documents.push(("../evil".to_owned(), b"x".to_vec())); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "traversal").await; + + let path = archive_dir.join("absolute.tar.gz"); + let mut documents = standard_documents(); + documents.push(("/evil".to_owned(), b"x".to_vec())); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "absolute").await; + + let path = archive_dir.join("duplicate.tar.gz"); + let mut documents = standard_documents(); + documents.push(( + POSTS_PATH.to_owned(), + document_bytes(&Vec::::new()), + )); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "duplicate").await; + + let path = archive_dir.join("duplicate-manifest.tar.gz"); + let mut documents = standard_documents(); + documents.push(( + MANIFEST_PATH.to_owned(), + document_bytes(&manifest_document(7)), + )); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "duplicate").await; + + let path = archive_dir.join("malformed.tar.gz"); + let mut documents = standard_documents(); + replace_document(&mut documents, POSTS_PATH, b"{".to_vec()); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "JSON").await; + + let path = archive_dir.join("sha-mismatch.tar.gz"); + let media_bytes = b"tiny archive media".to_vec(); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(media_bytes.len()).expect("media len"), + "0".repeat(64), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &settings, user, &path, "sha256").await; + + // The declared byte total must respect the settings-derived expanded + // limit, not the legacy compile-time constant. + let path = archive_dir.join("too-many-bytes.tar.gz"); + let media_bytes = b"tiny archive media".to_vec(); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + MAX_ARCHIVE_BYTES + 1, + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + write_test_archive(&path, documents, FORMAT_VERSION); + let bounded = adversarial_settings(64 * 1024, MAX_ENTRIES); + import_rejected(&instance, &bounded, user, &path, "declares more than").await; + + let path = archive_dir.join("not-gzip.tar.gz"); + fs::write(&path, b"not a gzip archive").expect("write"); + import_rejected(&instance, &settings, user, &path, "gzip tar").await; + + let path = archive_dir.join("corrupt-gzip-header.tar.gz"); + let mut bytes = fs::read(archive_dir.join("wrong-version.tar.gz")).expect("read archive"); + bytes[3] = 0xff; // invalid gzip compression method + fs::write(&path, bytes).expect("write"); + import_rejected(&instance, &settings, user, &path, "gzip tar").await; + } + + #[test] + fn capped_writer_rejects_output_past_the_limit() { + let mut writer = CappedWriter::new(Vec::new(), 4); + writer.write_all(b"abc").expect("within limit"); + let error = writer.write_all(b"de").expect_err("over limit"); + assert!(error.to_string().contains("limit"), "{error}"); + } + + #[tokio::test] + async fn export_allows_account_with_no_posts_or_media() { + let instance = test_instance().await; + let user = auth::register_user( + &instance.pool, + &instance.settings, + "empty_account", + "very secure password", + false, + ) + .await + .expect("user"); + let export_dir = instance.temp.path().join("exports"); + fs::create_dir_all(&export_dir).expect("export dir"); + let archive_path = export_dir.join("empty-account.tar.gz"); + + let report = export_account( + &instance.pool, + &instance.paths, + user, + &archive_path, + ArchiveLimits::from_settings(&instance.settings), + ) + .await + .expect("export"); + + assert_eq!(report.posts, 0); + assert_eq!(report.media, 0); + assert_eq!(report.follows, 0); + assert_eq!(report.muted_words, 0); + assert!(archive_path.is_file()); + } + + #[tokio::test] + async fn import_rejects_gzip_bomb_without_mutations() { + let instance = test_instance().await; + let user = register_user(&instance, "bomb_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // 4 MiB of zeros compresses to a few KiB, so only the decompressed + // limit can stop this archive. + let media_bytes = vec![0_u8; 4 * 1024 * 1024]; + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(media_bytes.len()).expect("media len"), + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + let path = archive_dir.join("bomb.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + let compressed_size = fs::metadata(&path).expect("archive metadata").len(); + assert!( + compressed_size < 64 * 1024, + "fixture must remain a tiny compressed bomb, got {compressed_size} bytes" + ); + + let settings = adversarial_settings(64 * 1024, MAX_ENTRIES); + import_rejected(&instance, &settings, user, &path, "declares").await; + } + + #[tokio::test] + async fn import_rejects_entry_count_over_limit() { + let instance = test_instance().await; + let user = register_user(&instance, "entry_limit").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let path = archive_dir.join("many-entries.tar.gz"); + write_test_archive(&path, standard_documents(), FORMAT_VERSION); + + let mut settings = instance.settings.clone(); + settings.accounts.max_archive_entries = 3; + import_rejected(&instance, &settings, user, &path, "more than 3 entries").await; + } + + #[tokio::test] + async fn import_rejects_media_entry_count_over_limit() { + let instance = test_instance().await; + let user = register_user(&instance, "media_count").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // One more media entry than media.json would ever accept must be + // rejected while extracting, not after staging a thousand files. + let mut documents = standard_documents(); + for index in 0..=MAX_MEDIA { + documents.push(( + format!("{MEDIA_PREFIX}{index}.png"), + png_bytes(b"media count"), + )); + } + let path = archive_dir.join("too-many-media-files.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "more than 1000 media files", + ) + .await; + } + + #[tokio::test] + async fn import_rejects_oversized_media_headers() { + let instance = test_instance().await; + let user = register_user(&instance, "oversized_header").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // The tar header itself declares more bytes than the expanded limit, + // so the entry must be rejected before any of it is written. + let media_bytes = vec![0x42_u8; 2 * 1024 * 1024]; + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(media_bytes.len()).expect("media len"), + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes)); + let path = archive_dir.join("oversized-entry.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + let settings = adversarial_settings(1024 * 1024, MAX_ENTRIES); + import_rejected(&instance, &settings, user, &path, "declares").await; + + // A hostile header may declare u64::MAX bytes with no data at all. + let mut entries = vec![( + MANIFEST_PATH.to_owned(), + EntryType::Regular, + document_bytes(&manifest_document(7)), + )]; + entries.extend( + standard_documents() + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)), + ); + let mut tar_bytes = Vec::new(); + for (name, entry_type, bytes) in &entries { + push_raw_entry(&mut tar_bytes, name, *entry_type, bytes); + } + // tar-rs itself rejects sizes that overflow its position counter, so + // declare the largest size it parses and let the entry header check + // stop it before any bytes are written. + push_raw_header( + &mut tar_bytes, + "media/0.png", + EntryType::Regular, + u64::MAX / 2, + ); + let path = archive_dir.join("huge-declared-header.tar.gz"); + write_gzip(&path, &tar_bytes); + import_rejected(&instance, &instance.settings, user, &path, "declares").await; + } + + #[tokio::test] + async fn import_rejects_declared_byte_len_overflow() { + let instance = test_instance().await; + let user = register_user(&instance, "overflow_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // One document alone declares more than the expanded limit. + let media_bytes = png_bytes(b"overflow-check"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::MAX, + sha256_hex(&media_bytes), + )]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("u64-max-declared.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "declares more than", + ) + .await; + + // Two documents whose declared total overflows a u64 addition. + let half = u64::MAX / 2 + 1; + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![ + media_document(0, "media/0.png", half, sha256_hex(&media_bytes)), + media_document(1, "media/1.png", half, sha256_hex(&media_bytes)), + ]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + documents.push(("media/1.png".to_owned(), media_bytes)); + let path = archive_dir.join("declared-total-overflow.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + // The largest representable limit still must not wrap on addition. + let settings = adversarial_settings(u64::MAX, MAX_ENTRIES); + import_rejected(&instance, &settings, user, &path, "overflow").await; + } + + #[tokio::test] + async fn import_rejects_total_document_bytes_over_limit() { + let instance = test_instance().await; + let user = register_user(&instance, "document_limit").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // Four maximum-size documents plus the manifest cross the cumulative + // 64 MiB document cap even though each one is individually allowed. + let big = vec![b'x'; usize::try_from(DOCUMENT_MAX_BYTES).expect("document size")]; + let mut documents = standard_documents(); + for name in [PROFILE_PATH, POSTS_PATH, MEDIA_PATH, FOLLOWS_PATH] { + replace_document(&mut documents, name, big.clone()); + } + let path = archive_dir.join("total-documents.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, "total limit").await; + } + + #[tokio::test] + async fn import_rejects_media_metadata_disagreement() { + let instance = test_instance().await; + let user = register_user(&instance, "metadata_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + // Declared PNG but the bytes are a JPEG. + let jpeg = jpeg_bytes(b"jpeg payload"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(jpeg.len()).expect("media len"), + sha256_hex(&jpeg), + )]), + ); + documents.push(("media/0.png".to_owned(), jpeg)); + let path = archive_dir.join("sniff-mismatch.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "does not match the declared", + ) + .await; + + // Bytes `infer` cannot classify must not be installed as media. + let junk = b"not a media file".to_vec(); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(junk.len()).expect("media len"), + sha256_hex(&junk), + )]), + ); + documents.push(("media/0.png".to_owned(), junk)); + let path = archive_dir.join("sniff-unknown.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "recognized media type", + ) + .await; + + // Declared byte_len disagreeing with the extracted size. + let png = png_bytes(b"byte len mismatch"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![test_media_document( + u64::try_from(png.len()).expect("media len") + 1, + sha256_hex(&png), + )]), + ); + documents.push(("media/0.png".to_owned(), png)); + let path = archive_dir.join("byte-len-mismatch.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, "does not match").await; + } + + #[tokio::test] + async fn import_rejects_media_document_mismatches() { + let instance = test_instance().await; + let user = register_user(&instance, "media_docs").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let media_bytes = png_bytes(b"media document"); + let media_len = u64::try_from(media_bytes.len()).expect("media len"); + let media_sha = sha256_hex(&media_bytes); + + // Duplicate media ids. + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![ + media_document(0, "media/0.png", media_len, media_sha.clone()), + media_document(0, "media/0.png", media_len, media_sha.clone()), + ]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("duplicate-media-ids.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "duplicate media id", + ) + .await; + + // The same archive path listed twice. + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![ + media_document(0, "media/0.png", media_len, media_sha.clone()), + media_document(1, "media/0.png", media_len, media_sha.clone()), + ]), + ); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("duplicate-media-paths.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, "more than once").await; + + // A listed file that is not in the archive. + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![media_document( + 0, + "media/0.png", + media_len, + media_sha.clone(), + )]), + ); + let path = archive_dir.join("listed-file-missing.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "missing from the archive", + ) + .await; + + // An archive media file that media.json never lists. + let mut documents = standard_documents(); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + let path = archive_dir.join("unlisted-media-file.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "unlisted media file", + ) + .await; + + // Case-variant media entry names collide on case-insensitive targets. + let mut documents = standard_documents(); + documents.push(("media/0.png".to_owned(), media_bytes.clone())); + documents.push(("media/0.PNG".to_owned(), media_bytes)); + let path = archive_dir.join("case-variant-media.tar.gz"); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "duplicate entry", + ) + .await; + } + + #[tokio::test] + async fn import_rejects_unsafe_entry_paths() { + let instance = test_instance().await; + let user = register_user(&instance, "path_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let cases = [ + ("média.png", "non-ASCII"), + ("media/../evil.png", "traversal"), + ("/evil.png", "absolute"), + ("media\\evil.png", "unsafe characters"), + ("C:evil.png", "unsafe characters"), + ("media/./evil.png", "invalid media path"), + ("media//evil.png", "unsafe characters"), + ("%2e%2e/evil.png", "unsafe characters"), + ("media/a/b.png", "invalid media path"), + ]; + for (index, (name, expected)) in cases.iter().enumerate() { + let mut documents = standard_documents(); + documents.push(((*name).to_owned(), png_bytes(b"path payload"))); + let path = archive_dir.join(format!("unsafe-{index}.tar.gz")); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, expected).await; + } + + // Media file names may not end in '.' or a space, and may not exceed + // the per-component length every common filesystem accepts. + let long_name = format!("{MEDIA_PREFIX}{}", "a".repeat(300)); + let tail_cases = [ + ("media/evil.png.", "ends with"), + ("media/evil.png ", "ends with"), + (long_name.as_str(), "too long"), + ]; + for (index, (name, expected)) in tail_cases.iter().enumerate() { + let mut documents = standard_documents(); + documents.push(((*name).to_owned(), png_bytes(b"path payload"))); + let path = archive_dir.join(format!("media-name-{index}.tar.gz")); + write_test_archive(&path, documents, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, expected).await; + } + } + + #[tokio::test] + async fn import_rejects_unsupported_entry_types() { + let instance = test_instance().await; + let user = register_user(&instance, "type_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let cases = [ + (EntryType::Symlink, "unsupported entry type"), + (EntryType::Link, "unsupported entry type"), + (EntryType::Char, "unsupported entry type"), + (EntryType::Fifo, "unsupported entry type"), + ]; + for (index, (entry_type, expected)) in cases.into_iter().enumerate() { + let mut entries = standard_documents() + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)) + .collect::>(); + entries.push(("media/link".to_owned(), entry_type, Vec::new())); + let path = archive_dir.join(format!("entry-type-{index}.tar.gz")); + write_typed_test_archive(&path, entries, FORMAT_VERSION); + import_rejected(&instance, &instance.settings, user, &path, expected).await; + } + + // A directory entry may only be the flat `media/` directory itself. + let mut entries = standard_documents() + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)) + .collect::>(); + entries.push(("media/foo".to_owned(), EntryType::Directory, Vec::new())); + let path = archive_dir.join("directory-media-foo.tar.gz"); + write_typed_test_archive(&path, entries, FORMAT_VERSION); + import_rejected( + &instance, + &instance.settings, + user, + &path, + "unexpected directory", + ) + .await; + } + + #[tokio::test] + async fn import_rejects_truncated_media_entry() { + let instance = test_instance().await; + let user = register_user(&instance, "truncated_target").await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let media_bytes = png_bytes(&vec![0xAB_u8; 4096]); + let media_len = u64::try_from(media_bytes.len()).expect("media len"); + let mut documents = standard_documents(); + replace_document( + &mut documents, + MEDIA_PATH, + document_bytes(&vec![media_document( + 0, + "media/0.png", + media_len, + sha256_hex(&media_bytes), + )]), + ); + + let mut entries = vec![( + MANIFEST_PATH.to_owned(), + EntryType::Regular, + document_bytes(&manifest_document(documents.len() + 2)), + )]; + entries.extend( + documents + .into_iter() + .map(|(name, bytes)| (name, EntryType::Regular, bytes)), + ); + let mut tar_bytes = Vec::new(); + for (name, entry_type, bytes) in &entries { + push_raw_entry(&mut tar_bytes, name, *entry_type, bytes); + } + push_raw_header(&mut tar_bytes, "media/0.png", EntryType::Regular, media_len); + tar_bytes.extend_from_slice(&media_bytes[..media_bytes.len() / 2]); + let path = archive_dir.join("truncated-media.tar.gz"); + write_gzip(&path, &tar_bytes); + + // tar-rs notices the entry's remaining declared bytes are missing when + // it advances to the next header, and reports the truncated stream. + import_rejected(&instance, &instance.settings, user, &path, "unexpected EOF").await; + } + + #[tokio::test] + async fn import_enforces_compressed_size_boundary() { + let instance = test_instance().await; + let archive_dir = instance.temp.path().join("archives"); + fs::create_dir_all(&archive_dir).expect("archive dir"); + + let path = archive_dir.join("boundary.tar.gz"); + write_test_archive(&path, standard_documents(), FORMAT_VERSION); + let size = fs::metadata(&path).expect("archive metadata").len(); + assert!(size > 0); + + let mut at_limit = instance.settings.clone(); + at_limit.accounts.max_archive_upload_bytes = size; + let at_limit_user = register_user(&instance, "at_limit").await; + let report = import_account( + &instance.pool, + &instance.paths, + &at_limit, + at_limit_user, + &path, + ) + .await + .expect("an archive exactly at the compressed limit must import"); + assert_eq!(report.posts_imported, 0); + + let mut over_limit = instance.settings.clone(); + over_limit.accounts.max_archive_upload_bytes = size - 1; + let over_limit_user = register_user(&instance, "over_limit").await; + import_rejected(&instance, &over_limit, over_limit_user, &path, "too large").await; + } +} diff --git a/src/rate_limit.rs b/src/rate_limit.rs index 67a98f7..15c7f35 100644 --- a/src/rate_limit.rs +++ b/src/rate_limit.rs @@ -26,6 +26,11 @@ impl Scope { } } +/// Atomically checks the limit and records the event. +/// +/// The check and the insert happen inside one database transaction so that +/// concurrent requests from the same actor cannot all observe an under-limit +/// state and slip past the limit together. pub async fn check_and_record( pool: &SqlitePool, scope: Scope, @@ -33,8 +38,29 @@ pub async fn check_and_record( max_events: i64, window_secs: i64, ) -> anyhow::Result<()> { - ensure_under_limit(pool, scope, actor, max_events, window_secs).await?; - record(pool, scope, actor).await + if max_events <= 0 { + anyhow::bail!("rate limit exceeded; try again later"); + } + let cutoff = format!("-{window_secs} seconds"); + let actor = actor.to_owned(); + pool.call(move |conn| { + let tx = conn.transaction()?; + let count: i64 = tx.query_row( + "SELECT COUNT(*) FROM rate_limit_events WHERE scope = ? AND actor = ? AND created_at >= datetime('now', ?)", + params![scope.as_str(), actor, cutoff], + |row| row.get(0), + )?; + if count >= max_events { + anyhow::bail!("rate limit exceeded; try again later"); + } + tx.execute( + "INSERT INTO rate_limit_events (scope, actor) VALUES (?, ?)", + params![scope.as_str(), actor], + )?; + tx.commit()?; + Ok(()) + }) + .await } pub async fn ensure_under_limit( diff --git a/src/registration_captcha.rs b/src/registration_captcha.rs index 1b75419..bdf4ed5 100644 --- a/src/registration_captcha.rs +++ b/src/registration_captcha.rs @@ -6,14 +6,13 @@ use anyhow::Context as _; use base64::Engine as _; use captcha::Captcha; use captcha::filters::{Dots, Noise, Wave}; -use rand_core::{OsRng, RngCore as _}; use sha2::{Digest as _, Sha256}; use tokio::sync::Mutex; use uuid::Uuid; const CAPTCHA_CHARS: &[u8] = b"123456789ABCDEFGHJKMNPQRSTUVWXYZ"; const CAPTCHA_LENGTH: usize = 5; -const CAPTCHA_TTL: Duration = Duration::from_secs(10 * 60); +const CAPTCHA_TTL: Duration = Duration::from_mins(10); #[cfg(debug_assertions)] const CAPTCHA_E2E_ANSWER_ENV: &str = "RUSTPOST_E2E_CAPTCHA_ANSWER"; @@ -62,7 +61,7 @@ struct StoredChallenge { impl RegistrationCaptchaStore { pub async fn create_challenge(&self) -> anyhow::Result { let token = Uuid::new_v4().to_string(); - let answer = challenge_answer(); + let answer = challenge_answer()?; let image_data_uri = captcha_image_data_uri(&answer)?; let expires_at = Instant::now() + CAPTCHA_TTL; let answer_digest = answer_digest(&token, &answer); @@ -129,23 +128,23 @@ impl RegistrationCaptchaStore { } } -fn challenge_answer() -> String { +fn challenge_answer() -> anyhow::Result { #[cfg(debug_assertions)] if let Ok(answer) = std::env::var(CAPTCHA_E2E_ANSWER_ENV) { let normalized = normalize_answer(&answer); if normalized.len() == CAPTCHA_LENGTH && normalized.bytes().all(|byte| CAPTCHA_CHARS.contains(&byte)) { - return normalized; + return Ok(normalized); } } let mut bytes = [0_u8; CAPTCHA_LENGTH]; - OsRng.fill_bytes(&mut bytes); - bytes + getrandom::fill(&mut bytes).context("failed to generate CAPTCHA randomness")?; + Ok(bytes .iter() .map(|byte| CAPTCHA_CHARS[usize::from(*byte) % CAPTCHA_CHARS.len()] as char) - .collect() + .collect()) } fn captcha_image_data_uri(answer: &str) -> anyhow::Result { diff --git a/src/render.rs b/src/render.rs index bdf9c84..7f3851b 100644 --- a/src/render.rs +++ b/src/render.rs @@ -1,7 +1,9 @@ +use std::fmt::Write as _; + use crate::auth::{CurrentUser, Theme}; use crate::social::{ - AccountView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, QuotePreview, - TimelineEventKind, + AccountView, FollowRequestView, MediaView, NotificationGroupView, PostView, ProfileTimelineTab, + QuotePreview, TimelineEventKind, }; use crate::youtube::{self, YoutubeEmbed}; use axum::http::StatusCode; @@ -15,6 +17,14 @@ pub struct LayoutContext { pub following_count: Option, pub notification_unread_count: Option, pub favicon_content_type: &'static str, + /// Enabled instance announcement, rendered beside the site name. + pub announcement: Option, + /// Maintenance notice, rendered under the header while maintenance is on. + pub maintenance_notice: Option, + /// Notice for accounts with a pending deletion deadline. + pub account_notice: Option, + /// Pending incoming follow requests, shown as a nav badge. + pub pending_follow_requests: Option, } impl Default for LayoutContext { @@ -26,6 +36,10 @@ impl Default for LayoutContext { following_count: None, notification_unread_count: None, favicon_content_type: "image/x-icon", + announcement: None, + maintenance_notice: None, + account_notice: None, + pending_follow_requests: None, } } } @@ -145,6 +159,7 @@ pub fn layout_with_context( String::new() }; let notifications = notification_nav_link(context.notification_unread_count.unwrap_or(0)); + let requests = follow_requests_nav_link(context.pending_follow_requests.unwrap_or(0)); let logout = csrf.map_or_else(String::new, |token| { format!( r#"
"#, @@ -154,7 +169,7 @@ pub fn layout_with_context( }); let profile = nav_link(&format!("/users/{}", user.username), "Profile", "profile"); format!( - "{}{}{}{notifications}{}{}{admin}{logout}", + "{}{}{}{notifications}{requests}{}{}{admin}{logout}", nav_link("/home", "Home Feed", "home"), nav_link("/following", "Following", "users"), nav_link("/search", "Search", "search"), @@ -175,6 +190,9 @@ pub fn layout_with_context( let side_panel = dashboard_panel(user, context); let theme = user.map_or(Theme::Light, |user| user.theme).as_str(); let header_tor = tor_header_indicator(context.tor_onion_address.as_deref()); + let announcement = announcement_banner(context.announcement.as_deref()); + let maintenance = maintenance_banner(context.maintenance_notice.as_deref()); + let account_notice = account_notice_banner(context.account_notice.as_deref()); format!( r#" @@ -191,7 +209,8 @@ pub fn layout_with_context( - + +{maintenance}{account_notice}
{}
{}
{}
{}
@@ -204,6 +223,7 @@ pub fn layout_with_context( CSS, html_escape::encode_text(&brand_mark.to_string()), html_escape::encode_text(site_name), + announcement, header_tor, auth_nav, left_rail, @@ -213,6 +233,33 @@ pub fn layout_with_context( ) } +fn announcement_banner(announcement: Option<&str>) -> String { + announcement.map_or_else(String::new, |announcement| { + format!( + r#"{}"#, + html_escape::encode_text(announcement) + ) + }) +} + +fn maintenance_banner(notice: Option<&str>) -> String { + notice.map_or_else(String::new, |notice| { + format!( + r#"

{}

"#, + html_escape::encode_text(notice) + ) + }) +} + +fn account_notice_banner(notice: Option<&str>) -> String { + notice.map_or_else(String::new, |notice| { + format!( + r#""#, + html_escape::encode_text(notice) + ) + }) +} + fn tor_header_indicator(onion: Option<&str>) -> String { onion.map_or_else(String::new, |onion| { let attr_onion = html_escape::encode_double_quoted_attribute(onion); @@ -278,6 +325,22 @@ fn notification_nav_link(unread_count: i64) -> String { } } +fn follow_requests_nav_link(pending_count: i64) -> String { + if pending_count > 0 { + format!( + r#"{}Requests {pending_count}"#, + icon_svg("user-check") + ) + } else { + nav_link("/follow-requests", "Requests", "user-check") + } +} + +/// Picks the singular or plural label for a count. +fn plural<'a>(count: i64, singular: &'a str, plural: &'a str) -> &'a str { + if count == 1 { singular } else { plural } +} + fn dashboard_panel(user: Option<&CurrentUser>, context: &LayoutContext) -> String { let posting = if context.anonymous_mode_enabled { "Signed-in and anonymous posting" @@ -302,8 +365,9 @@ fn dashboard_panel(user: Option<&CurrentUser>, context: &LayoutContext) -> Strin user.map_or_else(String::new, |user| { let username = html_escape::encode_double_quoted_attribute(&user.username); let display_name = html_escape::encode_double_quoted_attribute(&user.display_name); + let follower_label = plural(followers, "follower", "followers"); format!( - r#"
Social
{followers} followers
{following} following
"# + r#"
Social
{followers} {follower_label}
{following} following
"# ) }) } @@ -1051,6 +1115,36 @@ function setButtonState(button, active, 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 === 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 "Your session may have expired. Reload the page and sign in again."; + } + return "Something went wrong. Please try again."; +} + document.addEventListener("submit", async (event) => { const form = event.target.closest("form[data-enhance]"); if (!form || !window.fetch) { @@ -1061,6 +1155,7 @@ document.addEventListener("submit", async (event) => { return; } form.dataset.submitting = "true"; + form.setAttribute("aria-busy", "true"); const submitter = event.submitter || form.querySelector("button[type=submit]"); if (submitter) { submitter.disabled = true; @@ -1075,9 +1170,10 @@ document.addEventListener("submit", async (event) => { credentials: "same-origin" }); if (!response.ok) { - HTMLFormElement.prototype.submit.call(form); + 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}"]`); @@ -1085,15 +1181,17 @@ document.addEventListener("submit", async (event) => { 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 = data.following ? "Following" : "Follow"; + button.textContent = label; button.setAttribute("aria-pressed", data.following ? "true" : "false"); - button.setAttribute("aria-label", data.following ? "Unfollow this account" : "Follow this account"); - button.setAttribute("title", data.following ? "Unfollow this account" : "Follow this account"); + button.setAttribute("aria-label", aria); + button.setAttribute("title", aria); } } document.querySelectorAll(`[data-profile-followers="${data.user_id}"]`).forEach((node) => { - node.textContent = `${data.followers} followers`; + 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`; @@ -1103,10 +1201,10 @@ document.addEventListener("submit", async (event) => { const likes = post.querySelector('[data-count="likes"]'); const reposts = post.querySelector('[data-count="reposts"]'); if (likes) { - likes.textContent = `${data.likes} likes`; + likes.textContent = `${data.likes} ${data.likes === 1 ? "like" : "likes"}`; } if (reposts) { - reposts.textContent = `${data.reposts} 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"]'); @@ -1137,7 +1235,7 @@ document.addEventListener("submit", async (event) => { 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} replies`; + node.textContent = `${next} ${next === 1 ? "reply" : "replies"}`; }); } const created = document.getElementById(`post-${data.post_id}`); @@ -1153,9 +1251,15 @@ document.addEventListener("submit", async (event) => { form.querySelectorAll("input[type=file][data-composer-media]").forEach(updateComposerMedia); } } catch (_err) { - form.submit(); + // 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; } @@ -1335,11 +1439,15 @@ pub fn accounts(accounts: &[AccountView], csrf: &str) -> String { ) }, ); - let action = if account.viewer_following { - follow_form(account.id, csrf, true) - } else { - follow_form(account.id, csrf, false) - }; + let action = follow_form( + account.id, + csrf, + if account.viewer_following { + FollowButtonState::Following + } else { + FollowButtonState::Follow + }, + ); format!( r#""#, avatar, @@ -1601,27 +1709,58 @@ fn search_user_results(users: &[AccountView]) -> String { ) } -pub fn follow_form(user_id: i64, csrf: &str, following: bool) -> String { - let (action, label, aria_label) = if following { - ( +/// Follow button state on profiles, account lists, and search results. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum FollowButtonState { + /// No relationship; submitting sends a follow or a follow request. + Follow, + /// An accepted follow exists; submitting unfollows. + Following, + /// A pending request exists; submitting cancels the request. + Requested, +} + +impl FollowButtonState { + #[must_use] + pub const fn from_flags(following: bool, requested: bool) -> Self { + if following { + Self::Following + } else if requested { + Self::Requested + } else { + Self::Follow + } + } +} + +pub fn follow_form(user_id: i64, csrf: &str, state: FollowButtonState) -> String { + let (action, label, aria_label, active) = match state { + FollowButtonState::Following => ( format!("/users/{user_id}/unfollow"), "Following", "Unfollow this account", - ) - } else { - ( + true, + ), + FollowButtonState::Requested => ( + format!("/users/{user_id}/follow/cancel"), + "Requested", + "Cancel follow request", + false, + ), + FollowButtonState::Follow => ( format!("/users/{user_id}/follow"), "Follow", "Follow this account", - ) + false, + ), }; format!( r#"
"#, html_escape::encode_double_quoted_attribute(&action), user_id, html_escape::encode_double_quoted_attribute(csrf), - if following { " active" } else { "" }, - if following { "true" } else { "false" }, + if active { " active" } else { "" }, + if active { "true" } else { "false" }, html_escape::encode_double_quoted_attribute(aria_label), html_escape::encode_double_quoted_attribute(aria_label), html_escape::encode_text(label) @@ -2111,7 +2250,7 @@ fn post_card_with_options( .as_ref() .map_or_else(String::new, quote_preview_card); format!( - r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} likes{} reposts{} replies{}{}
{}
"#, + r#"
{}{}
{}
{}
{}
{}
{}{}{}
{} {}{} {}{} {}{}{}
{}
"#, post_class, post.id, post.id, @@ -2127,8 +2266,11 @@ fn post_card_with_options( media, quote, post.like_count, + plural(post.like_count, "like", "likes"), post.repost_count, + plural(post.repost_count, "repost", "reposts"), post.reply_count, + plural(post.reply_count, "reply", "replies"), edited, permalink, controls @@ -2381,28 +2523,87 @@ fn render_youtube_preview_card(preview: &YoutubeEmbed) -> String { ) } +/// Escapes post text and turns `#tags`, `@mentions`, and plain URLs into links. +/// +/// Whitespace, including newlines, is preserved so multi-line posts render the +/// way they were composed. pub fn linkify(text: &str) -> String { - html_escape::encode_text(text) - .split_whitespace() - .map(|word| { - if let Some(tag) = word.strip_prefix('#').filter(|value| !value.is_empty()) { - format!( - r##"#{}"##, - html_escape::encode_double_quoted_attribute(tag), - html_escape::encode_text(tag) - ) - } else if let Some(name) = word.strip_prefix('@').filter(|value| !value.is_empty()) { - format!( - r#"@{}"#, - html_escape::encode_double_quoted_attribute(name), - html_escape::encode_text(name) - ) - } else { - word.to_owned() - } + let mut out = String::with_capacity(text.len()); + let mut rest = text; + while !rest.is_empty() { + let whitespace_run = rest.chars().next().is_some_and(char::is_whitespace); + let end = rest + .find(|ch: char| ch.is_whitespace() != whitespace_run) + .unwrap_or(rest.len()); + let (chunk, tail) = rest.split_at(end); + if whitespace_run { + out.push_str(&html_escape::encode_text(chunk)); + } else { + out.push_str(&linkify_token(chunk)); + } + rest = tail; + } + out +} + +fn linkify_token(token: &str) -> String { + let leading_len = token + .find(|ch: char| !matches!(ch, '(' | '[' | '{' | '"' | '\'' | '<' | '“' | '‘')) + .unwrap_or(token.len()); + let (leading, remainder) = token.split_at(leading_len); + let core_len = remainder + .trim_end_matches(|ch: char| { + matches!( + ch, + '.' | ',' | '!' | '?' | ';' | ':' | ')' | ']' | '}' | '"' | '\'' | '”' | '’' + ) }) - .collect::>() - .join(" ") + .len(); + let (core, trailing) = remainder.split_at(core_len); + let mut out = html_escape::encode_text(leading).into_owned(); + if let Some(tag) = core + .strip_prefix('#') + .filter(|value| !value.is_empty() && value.chars().any(char::is_alphanumeric)) + { + let _ = write!( + out, + r##"#{}"##, + html_escape::encode_double_quoted_attribute(tag), + html_escape::encode_text(tag) + ); + } else if let Some(name) = core + .strip_prefix('@') + .filter(|value| !value.is_empty() && value.chars().any(char::is_alphanumeric)) + { + let _ = write!( + out, + r#"@{}"#, + html_escape::encode_double_quoted_attribute(name), + html_escape::encode_text(name) + ); + } else if is_linkable_url(core) { + let _ = write!( + out, + r#"{}"#, + html_escape::encode_double_quoted_attribute(core), + html_escape::encode_text(core) + ); + } else { + out.push_str(&html_escape::encode_text(core)); + } + out.push_str(&html_escape::encode_text(trailing)); + out +} + +fn is_linkable_url(value: &str) -> bool { + let Some(rest) = value + .strip_prefix("https://") + .or_else(|| value.strip_prefix("http://")) + else { + return false; + }; + let host = rest.split(['/', '?', '#']).next().unwrap_or_default(); + host.contains('.') && !host.starts_with('.') && !host.ends_with('.') } pub fn empty_state(title: &str, message: &str) -> String { @@ -2644,18 +2845,28 @@ fn notification_open_control( .map_or_else(String::new, |id| { format!(r#""#) }); + // Group kinds without a post target (for example follow requests) are + // marked read through their explicit notification ids. + let group_kind = if notification.kind == "follow" || notification.group_target_post_id.is_some() + { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(¬ification.kind) + ) + } else { + String::new() + }; format!( - r#"
{group_target}
"#, + r#"
{group_kind}{group_target}
"#, html_escape::encode_double_quoted_attribute(form_id), html_escape::encode_double_quoted_attribute(csrf), html_escape::encode_double_quoted_attribute(¬ification_ids), - html_escape::encode_double_quoted_attribute(¬ification.kind), html_escape::encode_double_quoted_attribute(target) ) } fn notification_preview(notification: &NotificationGroupView, target: Option<&str>) -> String { - if notification.kind == "follow" { + if is_account_notification(¬ification.kind) { return String::new(); } let text = if notification.post_available { @@ -2682,8 +2893,19 @@ fn notification_preview(notification: &NotificationGroupView, target: Option<&st } } +/// Notification kinds that point at an account rather than a post. +fn is_account_notification(kind: &str) -> bool { + matches!( + kind, + "follow" | "follow_request" | "follow_request_approved" + ) +} + fn notification_target(notification: &NotificationGroupView) -> Option { - if notification.kind == "follow" { + if notification.kind == "follow_request" { + return Some("/follow-requests".to_owned()); + } + if is_account_notification(¬ification.kind) { return notification .actors .first() @@ -2747,6 +2969,8 @@ fn notification_action_text(kind: &str) -> &'static str { "quote" => "quoted your post", "follow" => "followed you", "mention" => "mentioned you in a post", + "follow_request" => "requested to follow you", + "follow_request_approved" => "approved your follow request", _ => "sent you a notification", } } @@ -2759,6 +2983,8 @@ fn notification_group_action_text(kind: &str) -> &'static str { "quote" => "quoted your post", "follow" => "followed you", "mention" => "mentioned you in a post", + "follow_request" => "requested to follow you", + "follow_request_approved" => "approved your follow request", _ => "sent you notifications", } } @@ -2769,7 +2995,7 @@ fn notification_kind_label(kind: &str) -> &'static str { "like" => "L", "repost" => "Re", "quote" => "Q", - "follow" => "F", + "follow" | "follow_request" | "follow_request_approved" => "F", "mention" => "@", _ => "N", } @@ -2822,9 +3048,195 @@ pub fn thread_back_control() -> String { .to_owned() } +/// "Previously known as" note for profiles that changed handles. +pub fn username_history_note(history: &[crate::identity::UsernameHistoryEntry]) -> String { + if history.is_empty() { + return String::new(); + } + let handles = history + .iter() + .map(|entry| format!("@{}", entry.username)) + .collect::>() + .join(", "); + format!( + r#"

Previously known as {}

"#, + html_escape::encode_text(&handles) + ) +} + +/// Renders the page for a handle that no current account owns but that has +/// history on this instance, so old profile links cannot silently resolve to a +/// different person. +pub fn historical_username_page( + requested: &str, + holders: &[crate::identity::HistoricalUsernameHolder], +) -> String { + let rows = holders + .iter() + .map(|holder| { + format!( + r#"
  • {} @{} held until {}
  • "#, + html_escape::encode_double_quoted_attribute(&holder.username), + html_escape::encode_text(&holder.display_name), + html_escape::encode_text(&holder.username), + html_escape::encode_text(&holder.changed_at), + ) + }) + .collect::>() + .join(""); + format!( + r#"

    No account uses @{requested}

    This handle is not in use right now, but it was used before. RustPost does not redirect old profile links so they cannot silently point at a different person.

    Accounts that previously used this handle

      {rows}
    "#, + requested = html_escape::encode_text(requested), + ) +} + +/// Notice rendered on a profile whose handle was previously held by a +/// permanently deleted account, so an old link cannot silently appear to +/// represent the deleted account. +pub fn released_username_profile_note() -> String { + r#"

    This handle was previously used by an account that has since been permanently deleted. The current profile is a different account.

    "# + .to_owned() +} + +/// Page rendered for a handle released by a permanently deleted account. +/// +/// Used when no current account owns the handle. Old profile URLs remain +/// distinguishable from the deleted account without blocking the handle from +/// being claimed again. +pub fn released_username_page(requested: &str) -> String { + format!( + r#"

    No account uses @{requested}

    This handle was used by an account that has since been permanently deleted. The name is available again, and this page does not represent the deleted account.

    Register this handle

    "#, + requested = html_escape::encode_text(requested), + ) +} + +/// Renders the maintenance page shown for state-changing requests that are +/// disabled while maintenance mode is on. +pub fn maintenance_page(site_name: &str, notice: &str) -> String { + let body = format!( + r#"

    503 unavailable

    Maintenance in progress

    {}

    Back to Home Feed

    "#, + html_escape::encode_text(notice) + ); + layout(None, "Maintenance in progress", &body, site_name) +} + +/// Pending follow requests, split into incoming requests awaiting the viewer's +/// decision and outgoing requests the viewer can cancel. +pub fn follow_requests_page( + incoming: &[FollowRequestView], + outgoing: &[FollowRequestView], + csrf: &str, +) -> String { + format!( + r#"{}{}"#, + follow_request_section( + "Follow requests", + "Accounts that need your approval before they can follow you.", + incoming, + csrf, + FollowRequestDirection::Incoming, + ), + follow_request_section( + "Sent requests", + "Requests you sent to protected accounts. You can cancel them here.", + outgoing, + csrf, + FollowRequestDirection::Outgoing, + ), + ) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum FollowRequestDirection { + Incoming, + Outgoing, +} + +fn follow_request_section( + title: &str, + help: &str, + requests: &[FollowRequestView], + csrf: &str, + direction: FollowRequestDirection, +) -> String { + let content = if requests.is_empty() { + match direction { + FollowRequestDirection::Incoming => compact_empty_state( + "No follow requests.", + "Requests appear here when someone asks to follow a protected account.", + ), + FollowRequestDirection::Outgoing => compact_empty_state( + "No sent requests.", + "Requests you send to protected accounts appear here.", + ), + } + } else { + let rows = requests + .iter() + .map(|request| follow_request_row(request, csrf, direction)) + .collect::>() + .join(""); + format!(r#""#) + }; + format!( + r#"

    {}

    {}

    {content}
    "#, + html_escape::encode_text(title), + html_escape::encode_text(help), + ) +} + +fn follow_request_row( + request: &FollowRequestView, + csrf: &str, + direction: FollowRequestDirection, +) -> String { + let csrf = html_escape::encode_double_quoted_attribute(csrf); + let username = html_escape::encode_double_quoted_attribute(&request.username); + let actions = match direction { + FollowRequestDirection::Incoming => { + let approve = format!( + r#"
    "#, + request.user_id + ); + let reject = format!( + r#"
    "#, + request.user_id + ); + format!("{approve}{reject}") + } + FollowRequestDirection::Outgoing => format!( + r#"
    "#, + request.user_id + ), + }; + let avatar = request.profile_picture_path.as_ref().map_or_else( + || { + let initial = request.display_name.chars().next().unwrap_or('R'); + format!( + r#""#, + html_escape::encode_text(&initial.to_string()) + ) + }, + |path| { + format!( + r#""#, + html_escape::encode_double_quoted_attribute(path) + ) + }, + ); + format!( + r#""#, + html_escape::encode_text(&request.display_name), + html_escape::encode_text(&request.username), + html_escape::encode_text(&request.bio), + html_escape::encode_text(&request.created_at), + ) +} + pub fn notice(kind: &str, message: &str) -> String { + let role = if kind == "error" { "alert" } else { "status" }; format!( - r#"

    {}

    "#, + r#"

    {}

    "#, html_escape::encode_double_quoted_attribute(kind), html_escape::encode_text(message) ) @@ -2853,7 +3265,7 @@ pub fn error_page(status: StatusCode, message: &str) -> String { } const CSS: &str = r#" -: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:#93c5fd;--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{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)} @@ -2869,13 +3281,13 @@ main{padding:var(--shell-gap)}.app-shell{width:min(100%,var(--shell-max));margin .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:vertical;min-height:7rem}::placeholder{color:var(--muted)} +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:vertical}.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} +.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)} @@ -2883,9 +3295,20 @@ nav button{border-color:transparent;background:transparent;color:var(--link-stro .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}} -@media (max-width:600px){main{padding:.75rem}.header-inner{align-items:flex-start;flex-direction:column}.header-brand-row{align-items:center;width:100%;gap:.55rem}.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}} +@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}} "#; #[cfg(test)] @@ -2920,6 +3343,8 @@ mod tests { is_suspended: false, theme: Theme::Dark, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let body = layout(Some(&user), "Home Feed", "

    body

    ", "My Microblog"); @@ -3020,6 +3445,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let body = layout_with_csrf( Some(&user), @@ -3056,6 +3483,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let body = layout_with_context( Some(&user), @@ -3353,6 +3782,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let mut post = test_post(); post.user_id = Some(2); @@ -3387,6 +3818,8 @@ mod tests { is_suspended: false, theme: Theme::Light, nsfw_blur_enabled: true, + must_change_password: false, + deletion_scheduled_at: None, }; let mut post = test_post(); post.user_id = Some(2); @@ -3531,10 +3964,27 @@ mod tests { assert!(!body.contains("youtube-preview-card")); assert!(body.contains( - r#"
    hello https://example.com/watch?v=dQw4w9WgXcQ #rust
    "# + r#""# )); } + #[test] + fn linkify_preserves_newlines_and_trims_trailing_punctuation() { + assert_eq!( + linkify("first line\nsecond #rust, line"), + "first line\nsecond #rust, line" + ); + assert_eq!(linkify(" spaced \n\ntext "), " spaced \n\ntext "); + assert_eq!( + linkify("see (https://example.test/a)."), + "see (https://example.test/a)." + ); + assert_eq!(linkify("#"), "#"); + assert_eq!(linkify("@"), "@"); + assert_eq!(linkify("no links here"), "no links here"); + assert_eq!(linkify("hello@example.test"), "hello@example.test"); + } + #[test] fn multiple_youtube_links_render_up_to_documented_cap() { let mut post = test_post(); diff --git a/src/runtime.rs b/src/runtime.rs index cfab75b..54a7a51 100644 --- a/src/runtime.rs +++ b/src/runtime.rs @@ -1,10 +1,47 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +use std::sync::atomic::{AtomicUsize, Ordering}; use anyhow::Context as _; use tracing::{info, warn}; +/// Number of in-flight operations that stage files under the runtime temp +/// directory (media uploads, account archive export/import, restore uploads). +/// File cleanup is skipped while any operation is active so a slow but healthy +/// operation can never have its staging directory removed underneath it. +static ACTIVE_TEMP_OPERATIONS: AtomicUsize = AtomicUsize::new(0); + +/// Maximum directory entries inspected in one cleanup pass. Cleanup is +/// opportunistic; bounding each pass keeps a pathological temp directory from +/// stalling the maintenance scheduler. +const MAX_CLEANUP_SCAN_ENTRIES: usize = 10_000; + +/// Marks one long-running operation that stages files under +/// [`RuntimePaths::tmp_dir`]. The guard must live for the whole operation. +#[must_use] +pub struct TempOperationGuard { + _private: (), +} + +/// Begins a temp-staging operation. See [`TempOperationGuard`]. +pub fn begin_temp_operation() -> TempOperationGuard { + ACTIVE_TEMP_OPERATIONS.fetch_add(1, Ordering::SeqCst); + TempOperationGuard { _private: () } +} + +impl Drop for TempOperationGuard { + fn drop(&mut self) { + ACTIVE_TEMP_OPERATIONS.fetch_sub(1, Ordering::SeqCst); + } +} + +/// Whether any operation is currently staging files under `tmp_dir`. +#[must_use] +pub fn temp_operations_active() -> bool { + ACTIVE_TEMP_OPERATIONS.load(Ordering::SeqCst) > 0 +} + #[derive(Debug, Clone)] pub struct RuntimePaths { pub data_dir: PathBuf, @@ -92,6 +129,8 @@ impl RuntimePaths { })?; } self.migrate_legacy_database_layout()?; + restrict_dir(&self.data_dir)?; + restrict_dir(&self.backups_dir)?; restrict_dir(&self.tor_dir)?; restrict_dir(&self.tor_onion_service_dir)?; Ok(()) @@ -190,6 +229,86 @@ impl RuntimePaths { pub fn database_sidecar_path(&self, suffix: &str) -> PathBuf { self.db_dir.join(format!("rustpost.sqlite3-{suffix}")) } + + /// Removes leftover upload staging files, abandoned restore uploads, and + /// staged account export/import archives older than `max_age`. Called with + /// a zero age during startup, where no request can be in flight, and with + /// a grace period while running. + /// + /// Safety properties: + /// * Returns without touching anything while a staged operation is active, + /// so in-flight uploads/imports/exports are never removed. + /// * Only entries directly inside `tmp_dir` whose names match the known + /// staging prefixes are candidates; symlinks are unlinked, never + /// followed, and directories are removed only with `remove_dir_all` + /// after confirming they are not symlinks. + /// * Each pass inspects at most [`MAX_CLEANUP_SCAN_ENTRIES`] entries. + pub fn cleanup_stale_temp_files(&self, max_age: std::time::Duration) -> anyhow::Result { + if temp_operations_active() { + tracing::debug!("skipping temp cleanup while a staged operation is active"); + return Ok(0); + } + let mut removed = 0usize; + let entries = match fs::read_dir(&self.tmp_dir) { + Ok(entries) => entries, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(0), + Err(error) => { + return Err(error).with_context(|| { + format!("failed to read temp directory {}", self.tmp_dir.display()) + }); + } + }; + for (scanned, entry) in entries.enumerate() { + if scanned >= MAX_CLEANUP_SCAN_ENTRIES { + tracing::warn!( + limit = MAX_CLEANUP_SCAN_ENTRIES, + "temp cleanup stopped early; too many entries in the runtime temp directory" + ); + break; + } + let entry = entry?; + let path = entry.path(); + let Some(name) = path.file_name().and_then(|name| name.to_str()) else { + continue; + }; + let is_staging_name = name.ends_with(".upload") + || Path::new(name) + .extension() + .is_some_and(|extension| extension.eq_ignore_ascii_case("tmp")) + || name.starts_with("restore-upload-") + || name.starts_with(crate::portability::EXPORT_TMP_PREFIX) + || name.starts_with(crate::portability::IMPORT_TMP_PREFIX); + if !is_staging_name { + continue; + } + // Use `symlink_metadata` so a symlink is treated as a link (never + // followed) and only real directories are recursively removed. + let Ok(metadata) = fs::symlink_metadata(&path) else { + continue; + }; + let is_symlink = metadata.file_type().is_symlink(); + if !is_symlink && !metadata.is_file() && !metadata.is_dir() { + continue; + } + let stale = metadata + .modified() + .is_ok_and(|modified| modified.elapsed().is_ok_and(|age| age >= max_age)); + if !stale { + continue; + } + let removed_entry = if is_symlink || metadata.is_file() { + fs::remove_file(&path).is_ok() + } else { + fs::remove_dir_all(&path).is_ok() + }; + if removed_entry { + removed += 1; + } else { + tracing::warn!(path = %path.display(), "failed to remove stale temp entry"); + } + } + Ok(removed) + } } #[cfg(unix)] @@ -270,6 +389,27 @@ mod tests { } } + #[cfg(unix)] + #[test] + fn ensure_restricts_sensitive_runtime_directories() { + use std::os::unix::fs::PermissionsExt as _; + + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + + paths.ensure().expect("ensure paths"); + + for path in [ + &paths.data_dir, + &paths.backups_dir, + &paths.tor_dir, + &paths.tor_onion_service_dir, + ] { + let mode = fs::metadata(path).expect("metadata").permissions().mode() & 0o777; + assert_eq!(mode, 0o700, "{} should be private", path.display()); + } + } + #[test] fn ensure_migrates_legacy_database_when_new_database_is_absent() { let temp = tempfile::tempdir().expect("temp dir"); @@ -333,4 +473,145 @@ mod tests { b"new wal" ); } + + fn staging_paths() -> (tempfile::TempDir, RuntimePaths) { + let temp = tempfile::tempdir().expect("temp dir"); + let paths = RuntimePaths::from_data_dir(temp.path().join("data")); + paths.ensure().expect("ensure paths"); + (temp, paths) + } + + fn age_file(path: &Path, age: std::time::Duration) { + let modified = std::time::SystemTime::now() + .checked_sub(age) + .expect("system time supports subtraction"); + let file = fs::File::options() + .write(true) + .open(path) + .expect("open for mtime"); + file.set_modified(modified).expect("set modified"); + } + + #[test] + fn cleanup_removes_stale_known_staging_entries_only() { + let (_temp, paths) = staging_paths(); + let stale = std::time::Duration::from_hours(2); + let staging_file = paths.tmp_dir.join("account-import-abc.tar.gz"); + let export_file = paths.tmp_dir.join("account-export-def.tar.gz"); + let restore_file = paths.tmp_dir.join("restore-upload-1.tar"); + let upload_file = paths.tmp_dir.join("staged.upload"); + let tmp_file = paths.tmp_dir.join("scratch.tmp"); + let unrelated = paths.tmp_dir.join("keep.txt"); + for file in [ + &staging_file, + &export_file, + &restore_file, + &upload_file, + &tmp_file, + &unrelated, + ] { + fs::write(file, b"staged").expect("write staging file"); + age_file(file, stale); + } + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::from_hours(1)) + .expect("cleanup"); + + assert_eq!(removed, 5); + assert!(!staging_file.exists()); + assert!(!export_file.exists()); + assert!(!restore_file.exists()); + assert!(!upload_file.exists()); + assert!(!tmp_file.exists()); + assert!(unrelated.exists(), "unrelated files must be preserved"); + } + + #[test] + fn cleanup_removes_abandoned_import_staging_directories() { + let (_temp, paths) = staging_paths(); + let staging_dir = paths.tmp_dir.join("account-import-extract"); + fs::create_dir_all(&staging_dir).expect("staging dir"); + fs::write(staging_dir.join("media.bin"), b"staged").expect("staged media"); + let unrelated_dir = paths.tmp_dir.join("keep-dir"); + fs::create_dir_all(&unrelated_dir).expect("unrelated dir"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("startup cleanup"); + + assert_eq!(removed, 1); + assert!(!staging_dir.exists()); + assert!(unrelated_dir.is_dir(), "unrelated directories must remain"); + } + + #[test] + fn cleanup_respects_the_age_threshold() { + let (_temp, paths) = staging_paths(); + let fresh = paths.tmp_dir.join("account-import-fresh.tar.gz"); + fs::write(&fresh, b"fresh").expect("staging file"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::from_hours(1)) + .expect("cleanup"); + assert_eq!(removed, 0); + assert!(fresh.exists(), "a fresh upload must not be removed"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("startup cleanup"); + assert_eq!(removed, 1); + assert!(!fresh.exists()); + } + + #[test] + fn cleanup_skips_while_a_staged_operation_is_active() { + let (_temp, paths) = staging_paths(); + let stale = paths.tmp_dir.join("account-import-active.tar.gz"); + fs::write(&stale, b"active").expect("staging file"); + age_file(&stale, std::time::Duration::from_hours(4)); + + let operation = begin_temp_operation(); + assert!(temp_operations_active()); + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("cleanup"); + assert_eq!(removed, 0); + assert!( + stale.exists(), + "an active operation's staging file must survive cleanup" + ); + + drop(operation); + assert!(!temp_operations_active()); + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::from_hours(1)) + .expect("cleanup"); + assert_eq!(removed, 1); + assert!(!stale.exists()); + } + + #[cfg(unix)] + #[test] + fn cleanup_never_follows_staging_symlinks() { + let (_temp, paths) = staging_paths(); + let target_dir = paths.data_dir.join("outside"); + fs::create_dir_all(&target_dir).expect("target dir"); + let target_file = target_dir.join("important.bin"); + fs::write(&target_file, b"important").expect("target file"); + let link = paths.tmp_dir.join("account-import-link"); + std::os::unix::fs::symlink(&target_dir, &link).expect("symlink"); + + let removed = paths + .cleanup_stale_temp_files(std::time::Duration::ZERO) + .expect("cleanup"); + + assert_eq!(removed, 1); + assert!(!link.exists()); + assert!(target_dir.is_dir(), "symlink target directory must remain"); + assert_eq!( + fs::read(&target_file).expect("target file survives"), + b"important" + ); + } } diff --git a/src/server.rs b/src/server.rs index b6e777c..d45065e 100644 --- a/src/server.rs +++ b/src/server.rs @@ -27,7 +27,10 @@ use crate::errors::{AppError, AppResult}; use crate::ffmpeg::FfmpegStatus; use crate::registration_captcha::RegistrationCaptchaStore; use crate::runtime::RuntimePaths; -use crate::{account, admin, backup, csrf, favicon, media, rate_limit, render, social}; +use crate::{ + account, admin, backup, csrf, favicon, identity, instance, media, portability, rate_limit, + render, social, +}; const CSRF_TOKEN_HISTORY_LIMIT: usize = 32; @@ -39,6 +42,14 @@ pub struct AppState { pub ffmpeg: FfmpegStatus, pub tor: crate::tor::TorStatus, pub registration_captcha: RegistrationCaptchaStore, + /// Cached `media.nsfw_blur_enabled` so page rendering does not read and + /// parse `settings.toml` on every request. Updated when an admin saves + /// deep settings. + pub nsfw_blur_default: Arc, + /// Set after an in-process restore swaps the runtime directories. The + /// running process still holds the previous `SQLite` connection, so writes + /// are refused until the operator restarts `RustPost`. + pub restart_required: Arc, } impl AppState { @@ -50,6 +61,7 @@ impl AppState { ffmpeg: FfmpegStatus, tor: crate::tor::TorStatus, ) -> Arc { + let nsfw_blur_default = settings.media.nsfw_blur_enabled; Arc::new(Self { pool, settings, @@ -57,12 +69,289 @@ impl AppState { ffmpeg, tor, registration_captcha: RegistrationCaptchaStore::default(), + nsfw_blur_default: Arc::new(std::sync::atomic::AtomicBool::new(nsfw_blur_default)), + restart_required: Arc::new(std::sync::atomic::AtomicBool::new(false)), }) } } +/// Refuses state-changing requests after an in-process restore, because the +/// running process would write them to the replaced (unlinked) database file. +async fn restart_required_guard( + State(state): State>, + request: axum::extract::Request, + next: middleware::Next, +) -> Response { + use axum::http::Method; + + if state + .restart_required + .load(std::sync::atomic::Ordering::Relaxed) + && !matches!( + *request.method(), + Method::GET | Method::HEAD | Method::OPTIONS + ) + { + return ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::error_page( + StatusCode::SERVICE_UNAVAILABLE, + "A backup was restored. Restart RustPost before making changes so your data is written to the restored database.", + )), + ) + .into_response(); + } + next.run(request).await +} + +/// Paths that stay reachable while an account is restricted to a forced +/// password change. Static assets, logout, the password form, account +/// deletion, and data export must remain available. +fn password_change_exempt(path: &str) -> bool { + if path.starts_with("/assets/") || path.starts_with("/uploads/") { + return true; + } + matches!( + path, + "/login" + | "/settings/password" + | "/logout" + | "/favicon.ico" + | "/local" + | "/settings/delete" + | "/settings/delete/confirm" + | "/settings/delete/cancel" + | "/settings/export" + | "/account-deleted" + ) +} + +/// State-changing paths still allowed while an account is pending deletion. +/// Everything else is read-only until the deletion is cancelled. +fn pending_deletion_exempt(path: &str) -> bool { + matches!( + path, + "/logout" | "/settings/password" | "/settings/delete/cancel" | "/settings/delete/confirm" + ) +} + +fn is_state_changing(method: &axum::http::Method) -> bool { + !matches!( + *method, + axum::http::Method::GET + | axum::http::Method::HEAD + | axum::http::Method::OPTIONS + | axum::http::Method::TRACE + ) +} + +/// Enforces forced-password-reset and pending-deletion restrictions for the +/// authenticated viewer on every route, so a long-lived session cannot bypass +/// either account state. +async fn account_state_guard( + State(state): State>, + request: axum::extract::Request, + next: middleware::Next, +) -> Response { + let guarded_path = request.uri().path(); + if guarded_path.starts_with("/assets/") + || guarded_path.starts_with("/uploads/") + || guarded_path == "/favicon.ico" + { + return next.run(request).await; + } + if auth::session_cookie(request.headers()).is_none() { + return next.run(request).await; + } + let user = match auth::current_user(&state.pool, request.headers()).await { + Ok(Some(user)) => user, + Ok(None) => return next.run(request).await, + Err(error) => { + // Fail closed: an authenticated request whose account state cannot + // be verified must not bypass forced password or deletion checks. + tracing::warn!(error = %error, "account state lookup failed"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::error_page( + StatusCode::SERVICE_UNAVAILABLE, + "Your account state could not be verified. Try again shortly.", + )), + ) + .into_response(); + } + }; + let path = request.uri().path(); + if user.must_change_password && !password_change_exempt(path) { + return Redirect::to("/settings/password?required=1").into_response(); + } + if user.deletion_scheduled_at.is_some() + && is_state_changing(request.method()) + && !pending_deletion_exempt(path) + { + return ( + StatusCode::FORBIDDEN, + Html(render::error_page( + StatusCode::FORBIDDEN, + "This account is scheduled for deletion. Cancel the deletion in account settings to keep using it.", + )), + ) + .into_response(); + } + next.run(request).await +} + +/// Central maintenance-mode policy for mutating routes. +/// +/// Only routes that publish or rewrite public content are candidates. Reads, +/// authentication, account/security operations, social graph actions, and the +/// admin UI stay available while maintenance mode is on. Registration is +/// blocked for everyone; publish routes are blocked for non-administrators so +/// administrators can verify the instance and later disable maintenance. +/// +/// Keep this table authoritative: any new route that publishes content +/// (creates or rewrites posts/replies/quotes/reposts, or imports archives that +/// publish posts) must be added here, with a matching table-driven test. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum MaintenancePolicy { + /// Always reachable. + Allowed, + /// Blocked for every request. + Blocked, + /// Blocked for anonymous users and non-administrators. + BlockedUnlessAdmin, +} + +fn maintenance_policy(method: &axum::http::Method, path: &str) -> MaintenancePolicy { + if *method != axum::http::Method::POST { + return MaintenancePolicy::Allowed; + } + match path { + "/register" => MaintenancePolicy::Blocked, + "/posts" | "/settings/import" => MaintenancePolicy::BlockedUnlessAdmin, + _ if path.starts_with("/posts/") + && (path.ends_with("/quote") + || path.ends_with("/repost") + || path.ends_with("/edit")) => + { + MaintenancePolicy::BlockedUnlessAdmin + } + _ => MaintenancePolicy::Allowed, + } +} + +async fn maintenance_guard( + State(state): State>, + request: axum::extract::Request, + next: middleware::Next, +) -> Response { + let policy = maintenance_policy(request.method(), request.uri().path()); + if policy == MaintenancePolicy::Allowed { + return next.run(request).await; + } + let settings = match instance::load(&state.pool).await { + Ok(settings) => settings, + Err(error) => { + tracing::warn!(error = %error, "failed to load instance settings; refusing state change"); + return ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::maintenance_page( + &state.settings.site.name, + instance::DEFAULT_MAINTENANCE_MESSAGE, + )), + ) + .into_response(); + } + }; + if !settings.maintenance_mode { + return next.run(request).await; + } + if policy == MaintenancePolicy::BlockedUnlessAdmin + && let Ok(Some(user)) = auth::current_user(&state.pool, request.headers()).await + && user.is_admin + { + return next.run(request).await; + } + let notice = settings + .maintenance_notice() + .unwrap_or(instance::DEFAULT_MAINTENANCE_MESSAGE); + ( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::maintenance_page(&state.settings.site.name, notice)), + ) + .into_response() +} + +/// Periodically prunes expired operational rows and abandoned temp files. +/// +/// Startup performs an aggressive temp cleanup (nothing can be in flight yet); +/// later cycles keep a grace period so active uploads are never removed. +pub fn spawn_maintenance_scheduler( + pool: SqlitePool, + paths: RuntimePaths, + mut shutdown_rx: tokio::sync::watch::Receiver, +) { + tokio::spawn(async move { + // The longest configured rate-limit window is one hour; keep two hours. + const RATE_LIMIT_RETENTION_SECS: i64 = 2 * 60 * 60; + const MAINTENANCE_INTERVAL: std::time::Duration = std::time::Duration::from_mins(30); + let mut first_cycle = true; + loop { + match crate::account::recover_pending_media_deletions(&paths).await { + Ok(0) => {} + Ok(recovered) => tracing::info!( + recovered, + "recovered interrupted account deletion media cleanup" + ), + Err(error) => { + tracing::warn!(error = %error, "pending media deletion recovery failed"); + } + } + let max_temp_age = if first_cycle { + std::time::Duration::ZERO + } else { + std::time::Duration::from_hours(1) + }; + match paths.cleanup_stale_temp_files(max_temp_age) { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, "removed stale upload staging files"), + Err(error) => tracing::warn!(error = %error, "temp file cleanup failed"), + } + if let Err(error) = crate::rate_limit::prune_old(&pool, RATE_LIMIT_RETENTION_SECS).await + { + tracing::warn!(error = %error, "rate limit event pruning failed"); + } + match crate::auth::prune_stale_sessions(&pool, 7).await { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, "removed stale sessions"), + Err(error) => tracing::warn!(error = %error, "session pruning failed"), + } + match crate::account::finalize_due_deletions(&pool, &paths).await { + Ok(0) => {} + Ok(removed) => tracing::info!(removed, "finalized scheduled account deletions"), + Err(error) => { + tracing::warn!(error = %error, "scheduled account deletion finalization failed"); + } + } + first_cycle = false; + tokio::select! { + changed = shutdown_rx.changed() => { + if changed.is_err() || *shutdown_rx.borrow() { + break; + } + } + () = tokio::time::sleep(MAINTENANCE_INTERVAL) => {} + } + } + }); +} + +#[expect( + clippy::too_many_lines, + reason = "the route table is one flat registration list, matching the original layout" +)] pub fn router(state: Arc) -> Router { let upload_body_limit = upload_body_limit(&state.settings); + let import_body_limit = import_upload_body_limit(&state.settings); Router::new() .route("/", get(home)) .route("/assets/rustpost-boot.js", get(client_boot_script)) @@ -88,7 +377,11 @@ pub fn router(state: Arc) -> Router { .route("/users/{username}/followers", get(profile_followers)) .route("/users/{username}/following", get(profile_following)) .route("/users/{id}/follow", post(follow)) + .route("/users/{id}/follow/approve", post(approve_follow_request)) + .route("/users/{id}/follow/reject", post(reject_follow_request)) + .route("/users/{id}/follow/cancel", post(cancel_follow_request)) .route("/users/{id}/unfollow", post(unfollow)) + .route("/follow-requests", get(follow_requests)) .route("/users/{id}/block", post(block)) .route("/users/{id}/unblock", post(unblock)) .route("/users/{id}/mute", post(mute)) @@ -96,7 +389,19 @@ pub fn router(state: Arc) -> Router { .route("/settings", get(settings_form).post(settings_update)) .route("/settings/muted-words", post(add_muted_word)) .route("/settings/muted-words/{id}/remove", post(remove_muted_word)) - .route("/settings/password", post(change_password)) + .route( + "/settings/password", + get(password_change_page).post(change_password), + ) + .route("/settings/username", post(change_username)) + .route("/settings/export", get(export_account)) + .route( + "/settings/import", + get(import_account_form) + .post(import_account) + .layer(DefaultBodyLimit::max(import_body_limit)), + ) + .route("/settings/delete/cancel", post(cancel_deletion)) .route("/settings/delete", get(delete_account_warning)) .route( "/settings/delete/confirm", @@ -114,6 +419,16 @@ pub fn router(state: Arc) -> Router { .route("/admin", get(admin_dashboard)) .route("/admin/users", get(admin_users)) .route("/admin/users/{id}/suspend", post(admin_suspend)) + .route( + "/admin/users/{id}/require-password-reset", + post(admin_require_password_reset), + ) + .route( + "/admin/users/{id}/revoke-sessions", + post(admin_revoke_sessions), + ) + .route("/admin/announcement", post(admin_update_announcement)) + .route("/admin/maintenance", post(admin_update_maintenance)) .route("/admin/posts/{id}/delete", post(admin_delete_post)) .route("/admin/posts/{id}/nsfw", post(admin_toggle_post_nsfw)) .route("/admin/health", get(admin_health)) @@ -152,6 +467,18 @@ pub fn router(state: Arc) -> Router { ServeDir::new(state.paths.uploads_thumbs.clone()), ) .layer(DefaultBodyLimit::max(upload_body_limit)) + .layer(middleware::from_fn_with_state( + state.clone(), + account_state_guard, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + maintenance_guard, + )) + .layer(middleware::from_fn_with_state( + state.clone(), + restart_required_guard, + )) .layer(middleware::from_fn( crate::compression::response_compression, )) @@ -160,13 +487,53 @@ pub fn router(state: Arc) -> Router { } fn upload_body_limit(settings: &Settings) -> usize { - let limit = settings.media.max_video_size.saturating_add(1024 * 1024); + let limit = upload_payload_limit(settings).saturating_add(1024 * 1024); match usize::try_from(limit) { Ok(limit) => limit, Err(_overflow) => usize::MAX, } } +/// Body limit for `POST /settings/import`. The configured compressed archive +/// ceiling plus a small multipart/framing allowance so an oversized archive is +/// rejected by the streaming handler with a clear 413 page instead of a +/// transport-level body limit failure. The global media body limit stays +/// independent of archive imports. +fn import_upload_body_limit(settings: &Settings) -> usize { + const MULTIPART_SLACK_BYTES: u64 = 1024 * 1024; + let limit = settings + .accounts + .max_archive_upload_bytes + .saturating_add(MULTIPART_SLACK_BYTES); + usize::try_from(limit).unwrap_or(usize::MAX) +} + +fn upload_payload_limit(settings: &Settings) -> u64 { + let max_media = settings.posts.max_media_per_post; + if settings.media.max_video_size >= settings.media.max_image_size { + let videos = settings.posts.max_videos_per_post.min(max_media); + let images = settings + .posts + .max_images_per_post + .min(max_media.saturating_sub(videos)); + return attachment_bytes(videos, settings.media.max_video_size) + .saturating_add(attachment_bytes(images, settings.media.max_image_size)); + } + let images = settings.posts.max_images_per_post.min(max_media); + let videos = settings + .posts + .max_videos_per_post + .min(max_media.saturating_sub(images)); + attachment_bytes(images, settings.media.max_image_size) + .saturating_add(attachment_bytes(videos, settings.media.max_video_size)) +} + +fn attachment_bytes(count: usize, max_size: u64) -> u64 { + u64::try_from(count) + .unwrap_or(u64::MAX) + .saturating_mul(max_size) +} + async fn client_script() -> Response { ( [( @@ -254,6 +621,11 @@ struct ProfileQuery { tab: Option, } +#[derive(Deserialize)] +struct AccountListQuery { + after: Option, +} + #[derive(Deserialize)] struct MentionSuggestionsQuery { q: Option, @@ -274,6 +646,7 @@ struct AdminUsersQuery { #[derive(Deserialize)] struct SettingsQuery { saved: Option, + required: Option, } #[derive(Deserialize)] @@ -314,6 +687,7 @@ struct ParsedProfileUpdate { delete_banner: bool, nsfw_blur_enabled: bool, liked_posts_public: bool, + follow_approval_required: bool, profile_picture_media_id: Option, banner_media_id: Option, } @@ -369,6 +743,8 @@ struct FollowActionResponse { kind: &'static str, user_id: i64, following: bool, + /// A pending follow request exists (button shows "Requested"). + requested: bool, followers: i64, following_count: i64, action: String, @@ -407,7 +783,9 @@ async fn home(State(state): State>, headers: HeaderMap) -> AppResu let user = current(&state, &headers).await?; let posts = social::timeline(&state.pool, user.as_ref().map(|u| u.id), "local", None).await?; let csrf = form_csrf(&state, &headers).await; - let composer = if user.is_some() || state.settings.accounts.anonymous_mode_enabled { + let composer = if (user.is_some() || state.settings.accounts.anonymous_mode_enabled) + && posting_enabled_for(&state, user.as_ref()).await? + { render::composer(csrf.as_deref(), None, state.settings.posts.max_text_chars) } else { String::new() @@ -433,24 +811,34 @@ async fn layout_context( state: &AppState, user: Option<&CurrentUser>, ) -> AppResult { - let (counts, notification_unread_count) = if let Some(user) = user { + let (counts, notification_unread_count, pending_follow_requests) = if let Some(user) = user { ( Some(social::follow_counts(&state.pool, user.id).await?), Some(social::unread_notification_count(&state.pool, user.id).await?), + Some(social::pending_follow_request_count(&state.pool, user.id).await?), ) } else { - (None, None) + (None, None, None) }; + let instance = instance::load(&state.pool).await.unwrap_or_else(|error| { + tracing::warn!(error = %error, "failed to load instance settings"); + instance::InstanceSettings::default() + }); Ok(render::LayoutContext { anonymous_mode_enabled: state.settings.accounts.anonymous_mode_enabled, - tor_onion_address: state.tor.onion_address().or_else(|| { - (!state.settings.tor.display_onion_address.is_empty()) - .then(|| state.settings.tor.display_onion_address.clone()) - }), + tor_onion_address: state.tor.onion_address(), follower_count: counts.map(|(followers, _following)| followers), following_count: counts.map(|(_followers, following)| following), notification_unread_count, favicon_content_type: favicon::current(&state.paths).content_type(), + announcement: instance.announcement_text().map(ToOwned::to_owned), + maintenance_notice: instance.maintenance_notice().map(ToOwned::to_owned), + account_notice: user.and_then(|user| { + user.deletion_scheduled_at + .as_ref() + .map(|deadline| format!("This account is scheduled for deletion on {deadline}.")) + }), + pending_follow_requests, }) } @@ -472,11 +860,17 @@ async fn page_layout( )) } +/// Whether the viewer may publish content right now. Maintenance mode blocks +/// posting for everyone except administrators. +async fn posting_enabled_for(state: &AppState, user: Option<&CurrentUser>) -> AppResult { + let instance = instance::load(&state.pool).await?; + Ok(!instance.maintenance_mode || user.is_some_and(|user| user.is_admin)) +} + fn blur_nsfw_media(state: &AppState, user: Option<&CurrentUser>) -> bool { - let global_blur = Settings::load(&state.paths.settings_path) - .map_or(state.settings.media.nsfw_blur_enabled, |settings| { - settings.media.nsfw_blur_enabled - }); + let global_blur = state + .nsfw_blur_default + .load(std::sync::atomic::Ordering::Relaxed); global_blur && user.is_none_or(|user| user.nsfw_blur_enabled) } @@ -496,8 +890,10 @@ async fn login_form(State(state): State>) -> Html { async fn login( State(state): State>, ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, Form(form): Form, ) -> AppResult { + reject_cross_site_form_post(&headers)?; let actor = ip_actor(addr); rate_limit::ensure_under_limit( &state.pool, @@ -508,10 +904,6 @@ async fn login( ) .await .map_err(|err| AppError::RateLimited(err.to_string()))?; - if let Err(err) = crate::validation::validate_password(&form.password, &state.settings) { - rate_limit::record(&state.pool, rate_limit::Scope::FailedLogin, &actor).await?; - return auth_form_response(&state, StatusCode::BAD_REQUEST, &err.to_string()).await; - } let session = match auth::login(&state.pool, &form.username, &form.password).await? { Ok(session) => session, Err(failure) => { @@ -540,7 +932,20 @@ async fn register_form(State(state): State>) -> AppResult

    Registration is currently closed.

    {}"#, + render::notice( + "info", + instance + .maintenance_notice() + .unwrap_or(instance::DEFAULT_MAINTENANCE_MESSAGE) + ) + ) + } else { + register_form_body(&state, None).await? + }; Ok(Html( page_layout(&state, None, None, "Register", &body).await?, )) @@ -549,11 +954,23 @@ async fn register_form(State(state): State>) -> AppResult>, ConnectInfo(addr): ConnectInfo, + headers: HeaderMap, Form(form): Form, ) -> AppResult { + reject_cross_site_form_post(&headers)?; if !state.settings.accounts.registration_enabled { return Err(AppError::Forbidden); } + if instance::load(&state.pool).await?.maintenance_mode { + return Ok(( + StatusCode::SERVICE_UNAVAILABLE, + Html(render::maintenance_page( + &state.settings.site.name, + instance::DEFAULT_MAINTENANCE_MESSAGE, + )), + ) + .into_response()); + } let Some(confirm_password) = form.confirm_password.as_deref() else { return Err(AppError::BadRequest( "please confirm your password".to_owned(), @@ -717,6 +1134,7 @@ async fn onboarding_update( if let Some(media_id) = form.profile_picture_media_id { media::set_profile_media( &state.pool, + &state.paths, user.id, media::ProfileMediaSlot::Picture, media_id, @@ -736,7 +1154,7 @@ async fn onboarding_update( async fn cleanup_onboarding_uploads(state: &AppState, form: &ParsedOnboardingUpdate) { if let Some(media_id) = form.profile_picture_media_id - && let Err(error) = media::delete_media(&state.pool, media_id).await + && let Err(error) = media::delete_media(&state.pool, &state.paths, media_id).await { tracing::warn!( media_id, @@ -971,16 +1389,31 @@ async fn create_post( } Err(err) => return Err(err), }; + let outcome = finish_post_create(&state, &headers, addr, user.as_ref(), &form).await; + if outcome.is_err() { + cleanup_post_uploads(&state, &form.media_ids).await; + } + outcome +} + +async fn finish_post_create( + state: &AppState, + headers: &HeaderMap, + addr: SocketAddr, + user: Option<&CurrentUser>, + form: &ParsedPostCreate, +) -> AppResult { if let Some(parent_id) = form.parent_post_id { ensure_parent_post_exists(&state.pool, parent_id).await?; } if user.is_some() { - validate_csrf(&state.pool, &headers, &form.csrf_token).await?; + validate_csrf(&state.pool, headers, &form.csrf_token).await?; } if form.is_nsfw { media::set_media_nsfw(&state.pool, &form.media_ids, true).await?; } - let (scope, actor, max_events, window_secs) = if user.is_none() { + let viewer_id = user.map(|user| user.id); + let (scope, actor, max_events, window_secs) = if viewer_id.is_none() { ( rate_limit::Scope::AnonymousPost, ip_actor(addr), @@ -990,14 +1423,14 @@ async fn create_post( } else if form.parent_post_id.is_some() { ( rate_limit::Scope::Reply, - user_actor(user.as_ref().map(|u| u.id).unwrap_or_default()), + user_actor(viewer_id.unwrap_or_default()), state.settings.moderation.replies_per_minute, 60, ) } else { ( rate_limit::Scope::Post, - user_actor(user.as_ref().map(|u| u.id).unwrap_or_default()), + user_actor(viewer_id.unwrap_or_default()), state.settings.moderation.posts_per_minute, 60, ) @@ -1008,7 +1441,7 @@ async fn create_post( let post_id = social::create_post( &state.pool, &state.settings, - user.as_ref().map(|u| u.id), + viewer_id, &form.text, form.parent_post_id, &form.media_ids, @@ -1019,8 +1452,8 @@ async fn create_post( || format!("/home#post-{post_id}"), |_| format!("/posts/{post_id}#reply-{post_id}"), ); - if enhanced_request(&headers) { - let posts = social::post_thread(&state.pool, user.as_ref().map(|u| u.id), post_id).await?; + if enhanced_request(headers) { + let posts = social::post_thread(&state.pool, viewer_id, post_id).await?; let post = posts .iter() .find(|post| post.id == post_id) @@ -1033,17 +1466,17 @@ async fn create_post( html: if form.parent_post_id.is_some() { render::thread_post_card_with_controls( post, - user.as_ref(), - form_csrf(&state, &headers).await.as_deref(), - blur_nsfw_media(&state, user.as_ref()), + user, + form_csrf(state, headers).await.as_deref(), + blur_nsfw_media(state, user), state.settings.posts.post_edit_window_seconds, ) } else { render::post_card_with_controls( post, - user.as_ref(), - form_csrf(&state, &headers).await.as_deref(), - blur_nsfw_media(&state, user.as_ref()), + user, + form_csrf(state, headers).await.as_deref(), + blur_nsfw_media(state, user), state.settings.posts.post_edit_window_seconds, ) }, @@ -1053,10 +1486,24 @@ async fn create_post( Ok(Redirect::to(&redirect).into_response()) } +/// Deletes media rows and files that were uploaded for a post that was never +/// created, so rejected or failed submissions do not leave orphaned uploads. +async fn cleanup_post_uploads(state: &AppState, media_ids: &[i64]) { + for media_id in media_ids { + if let Err(error) = media::delete_media(&state.pool, &state.paths, *media_id).await { + tracing::warn!( + media_id, + error = %error, + "failed to clean up media uploaded for a rejected post" + ); + } + } +} + async fn parse_post_create( state: &AppState, user_id: Option, - mut multipart: Multipart, + multipart: Multipart, ) -> AppResult { let mut form = ParsedPostCreate { csrf_token: String::new(), @@ -1065,6 +1512,19 @@ async fn parse_post_create( media_ids: Vec::new(), is_nsfw: false, }; + let outcome = fill_post_create_form(state, user_id, multipart, &mut form).await; + if outcome.is_err() { + cleanup_post_uploads(state, &form.media_ids).await; + } + outcome.map(|()| form) +} + +async fn fill_post_create_form( + state: &AppState, + user_id: Option, + mut multipart: Multipart, + form: &mut ParsedPostCreate, +) -> AppResult<()> { while let Some(field) = multipart .next_field() .await @@ -1115,6 +1575,11 @@ async fn parse_post_create( .file_name() .is_some_and(|name| !name.trim().is_empty()) => { + if form.media_ids.len() >= state.settings.posts.max_media_per_post { + return Err(AppError::BadRequest( + "too many media attachments".to_owned(), + )); + } form.media_ids.push( media::save_upload( &state.pool, @@ -1131,7 +1596,7 @@ async fn parse_post_create( _ => {} } } - Ok(form) + Ok(()) } async fn bad_request_page( @@ -1161,7 +1626,9 @@ async fn thread( return Err(AppError::NotFound); } let csrf = form_csrf(&state, &headers).await; - let composer = if user.is_some() || state.settings.accounts.anonymous_mode_enabled { + let composer = if (user.is_some() || state.settings.accounts.anonymous_mode_enabled) + && posting_enabled_for(&state, user.as_ref()).await? + { render::composer( csrf.as_deref(), Some(id), @@ -1302,7 +1769,13 @@ async fn delete_post( } Err(err) => return Err(err), }; + media::validate_post_media_deletion(&state.pool, &state.paths, id).await?; social::delete_post(&state.pool, user.id, id, user.is_admin).await?; + // The post is already deleted; a media cleanup failure should not turn a + // successful deletion into an error page. + if let Err(error) = media::delete_post_media(&state.pool, &state.paths, id).await { + tracing::warn!(post_id = id, error = %error, "post deleted but media cleanup failed"); + } let fallback = if let Some(parent_id) = preview.parent_post_id { format!("/posts/{parent_id}#post-{parent_id}") } else { @@ -1466,6 +1939,36 @@ async fn reply_redirect(Path(id): Path) -> Redirect { Redirect::to(&format!("/posts/{id}")) } +/// Renders the page for a handle no account currently owns. +/// +/// When the handle appears in `username_history`, the page lists the accounts +/// that previously held it instead of silently resolving to a different +/// person. Unknown handles keep the normal 404 response. +async fn historical_username_page( + state: &AppState, + user: Option<&CurrentUser>, + headers: &HeaderMap, + requested: &str, +) -> AppResult> { + let normalized = requested.trim().to_ascii_lowercase(); + let holders = identity::historical_username_holders(&state.pool, &normalized).await?; + if holders.is_empty() { + if instance::username_was_released(&state.pool, &normalized).await? { + let csrf = form_csrf(state, headers).await; + let body = render::released_username_page(&normalized); + return Ok(Html( + page_layout(state, user, csrf.as_deref(), "Released username", &body).await?, + )); + } + return Err(AppError::NotFound); + } + let csrf = form_csrf(state, headers).await; + let body = render::historical_username_page(&normalized, &holders); + Ok(Html( + page_layout(state, user, csrf.as_deref(), "Username history", &body).await?, + )) +} + fn profile_tab_from_query(tab: Option<&str>) -> social::ProfileTimelineTab { match tab { Some("replies") => social::ProfileTimelineTab::Replies, @@ -1505,6 +2008,7 @@ async fn profile( ) -> AppResult> { let user = current(&state, &headers).await?; let active_tab = profile_tab_from_query(query.tab.as_deref()); + let requested_username = username.clone(); let profile = state .pool .call(move |conn| { @@ -1553,12 +2057,14 @@ async fn profile( is_suspended, )) = profile else { - return Err(AppError::NotFound); + return historical_username_page(&state, user.as_ref(), &headers, &requested_username) + .await; }; let csrf = form_csrf(&state, &headers).await; let viewer_id = user.as_ref().map(|u| u.id); let owner_or_admin = viewer_id == Some(profile_id) || user.as_ref().is_some_and(|viewer| viewer.is_admin); + let relationship = social::profile_relationship(&state.pool, viewer_id, profile_id).await?; let activity_visible = (!is_suspended || owner_or_admin) && social::profile_activity_visible(&state.pool, viewer_id, profile_id).await?; let likes_visible = activity_visible && (liked_posts_public || viewer_id == Some(profile_id)); @@ -1580,7 +2086,7 @@ async fn profile( }); } let (followers, following) = social::follow_counts(&state.pool, profile_id).await?; - let controls = profile_controls(&state, user.as_ref(), csrf.as_deref(), profile_id).await?; + let controls = profile_controls(user.as_ref(), csrf.as_deref(), profile_id, relationship); let picture = picture_path.map_or_else( || r#""#.to_owned(), |path| { @@ -1599,23 +2105,39 @@ async fn profile( ) }, ); - let website_link = if website.trim().is_empty() { + let website_link = render_profile_website_link(&website); + let location_line = if location.trim().is_empty() { String::new() } else { format!( - r#"

    {}

    "#, - html_escape::encode_double_quoted_attribute(website.as_str()), - html_escape::encode_text(website.as_str()) + r#"

    {}

    "#, + html_escape::encode_text(location.as_str()) ) }; - let location_line = if location.trim().is_empty() { + let bio_line = if bio.trim().is_empty() { String::new() } else { format!( - r#"

    {}

    "#, - html_escape::encode_text(location.as_str()) + r#"

    {}

    "#, + html_escape::encode_text(bio.as_str()) ) }; + let profile_state_note = + profile_state_note(is_suspended, owner_or_admin, relationship, activity_visible); + let mut username_history = + render::username_history_note(&identity::username_history(&state.pool, profile_id).await?); + if instance::username_was_released(&state.pool, &profile_username.to_ascii_lowercase()).await? { + username_history.push_str(&render::released_username_profile_note()); + } + let counts = format!( + r#"

    {}{}

    "#, + html_escape::encode_double_quoted_attribute(&profile_username), + profile_id, + count_label(followers, "follower", "followers"), + html_escape::encode_double_quoted_attribute(&profile_username), + profile_id, + count_label(following, "following", "following"), + ); let pinned = pinned_post.as_ref().map_or_else(String::new, |post| { render::pinned_post_with_controls( post, @@ -1627,14 +2149,17 @@ async fn profile( }); let tabs = render::profile_tabs(&profile_username, active_tab); let timeline = if !activity_visible { - render::posts_with_controls_empty_state( - &posts, - user.as_ref(), - csrf.as_deref(), - blur_nsfw_media(&state, user.as_ref()), - state.settings.posts.post_edit_window_seconds, - profile_tab_empty_state(active_tab), - ) + match profile_state_note { + Some((title, message)) => render::empty_state(title, message), + None => render::posts_with_controls_empty_state( + &posts, + user.as_ref(), + csrf.as_deref(), + blur_nsfw_media(&state, user.as_ref()), + state.settings.posts.post_edit_window_seconds, + profile_tab_empty_state(active_tab), + ), + } } else if active_tab == social::ProfileTimelineTab::Likes && !likes_visible { render::empty_state("This user’s likes are private", "") } else { @@ -1648,18 +2173,16 @@ async fn profile( ) }; let body = format!( - r#"
    {}
    {}

    {}

    @{}

    {}

    {} followers{} following

    {}

    {}

    {}
    {}{}{}"#, + r#"
    {}
    {}

    {}

    @{}

    {}
    {}{}{}{}{}
    {}{}{}"#, banner, picture, html_escape::encode_text(display_name.as_str()), html_escape::encode_text(profile_username.as_str()), controls, - profile_id, - followers, - profile_id, - following, + counts, + username_history, + bio_line, location_line, - html_escape::encode_text(bio.as_str()), website_link, pinned, tabs, @@ -1677,6 +2200,52 @@ async fn profile( )) } +/// A human-readable explanation for why a profile's activity is hidden. +fn profile_state_note( + is_suspended: bool, + owner_or_admin: bool, + relationship: social::ProfileRelationship, + activity_visible: bool, +) -> Option<(&'static str, &'static str)> { + if activity_visible || owner_or_admin { + return None; + } + if relationship.blocked { + return Some(( + "You blocked this account", + "Unblock this account to see their posts and interact again.", + )); + } + if relationship.blocks_viewer { + return Some(( + "Activity unavailable", + "This account's posts and replies are not visible to you.", + )); + } + if relationship.muted { + return Some(( + "You muted this account", + "Their posts stay hidden from your feeds until you unmute them.", + )); + } + if is_suspended { + return Some(( + "Account suspended", + "This account's posts and replies are not visible.", + )); + } + None +} + +/// Renders "1 follower" / "2 followers" without a separate plural forms crate. +fn count_label(count: i64, singular: &str, plural: &str) -> String { + if count == 1 { + format!("{count} {singular}") + } else { + format!("{count} {plural}") + } +} + async fn profile_identity( pool: &SqlitePool, username: &str, @@ -1702,6 +2271,7 @@ async fn profile_followers( State(state): State>, headers: HeaderMap, Path(username): Path, + Query(query): Query, ) -> AppResult> { let user = current(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await; @@ -1710,11 +2280,15 @@ async fn profile_followers( else { return Err(AppError::NotFound); }; - let accounts = - social::followers_accounts(&state.pool, profile_id, user.as_ref().map(|user| user.id)) - .await?; + let (accounts, has_more) = social::followers_accounts( + &state.pool, + profile_id, + user.as_ref().map(|user| user.id), + query.after, + ) + .await?; let body = format!( - "{}{}", + "{}{}{}", render::page_header( &format!("{display_name} followers"), &format!("Users who follow @{profile_username}.") @@ -1722,6 +2296,11 @@ async fn profile_followers( render::account_links_with_empty_state( &accounts, render::EmptyState::new("No followers yet.", "Followers will appear here.",), + ), + account_list_next_link( + &format!("/users/{profile_username}/followers"), + &accounts, + has_more ) ); Ok(Html( @@ -1740,6 +2319,7 @@ async fn profile_following( State(state): State>, headers: HeaderMap, Path(username): Path, + Query(query): Query, ) -> AppResult> { let user = current(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await; @@ -1748,14 +2328,15 @@ async fn profile_following( else { return Err(AppError::NotFound); }; - let accounts = social::following_accounts_for_profile( + let (accounts, has_more) = social::following_accounts_for_profile( &state.pool, profile_id, user.as_ref().map(|user| user.id), + query.after, ) .await?; let body = format!( - "{}{}", + "{}{}{}", render::page_header( &format!("{display_name} following"), &format!("Users @{profile_username} follows.") @@ -1766,6 +2347,11 @@ async fn profile_following( "Not following anyone yet.", "Followed accounts will appear here.", ), + ), + account_list_next_link( + &format!("/users/{profile_username}/following"), + &accounts, + has_more ) ); Ok(Html( @@ -1780,27 +2366,71 @@ async fn profile_following( )) } -async fn profile_controls( - state: &AppState, +/// Renders the "Show more" link for keyset-paginated account lists. +fn account_list_next_link(base: &str, accounts: &[social::AccountView], has_more: bool) -> String { + if !has_more { + return String::new(); + } + let Some(last) = accounts.last() else { + return String::new(); + }; + let cursor = last.username.to_ascii_lowercase(); + let cursor = html_escape::encode_double_quoted_attribute(&cursor); + let base = html_escape::encode_double_quoted_attribute(base); + format!( + r#""# + ) +} + +fn profile_controls( user: Option<&CurrentUser>, csrf: Option<&str>, profile_id: i64, -) -> AppResult { + relationship: social::ProfileRelationship, +) -> String { let (Some(viewer), Some(csrf)) = (user, csrf) else { - return Ok(String::new()); + return String::new(); }; if viewer.id == profile_id { - return Ok( - r#""# - .to_owned(), + return r#""# + .to_owned(); + } + if relationship.blocks_viewer { + return r#"
    This account blocked you.
    "# + .to_owned(); + } + if relationship.blocked { + return format!( + r#"
    You blocked this account.{}
    "#, + small_form( + &format!("/users/{profile_id}/unblock"), + csrf, + "Unblock", + "Unblock this account" + ) ); } let follow_action = render::follow_form( profile_id, csrf, - social::is_following(&state.pool, viewer.id, profile_id).await?, + render::FollowButtonState::from_flags(relationship.following, relationship.requested), ); - Ok(format!( + let mute_action = if relationship.muted { + small_form( + &format!("/users/{profile_id}/unmute"), + csrf, + "Unmute", + "Unmute this account", + ) + } else { + small_form( + &format!("/users/{profile_id}/mute"), + csrf, + "Mute", + "Mute this account", + ) + }; + format!( r#"
    {}{}{}
    "#, follow_action, small_form( @@ -1809,13 +2439,8 @@ async fn profile_controls( "Block", "Block this account" ), - small_form( - &format!("/users/{profile_id}/mute"), - csrf, - "Mute", - "Mute this account" - ) - )) + mute_action + ) } async fn follow( @@ -1835,7 +2460,7 @@ async fn follow( Ok(Redirect::to(&account_action_return(&state.pool, &headers, id).await?).into_response()) } -async fn unfollow( +async fn approve_follow_request( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -1843,14 +2468,13 @@ async fn unfollow( ) -> AppResult { let user = require_active_user(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; - social::unfollow(&state.pool, user.id, id).await?; - if enhanced_request(&headers) { - return Ok(Json(follow_action_response(&state.pool, user.id, id).await?).into_response()); - } - Ok(Redirect::to(&account_action_return(&state.pool, &headers, id).await?).into_response()) + social::approve_follow_request(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/follow-requests").into_response()) } -async fn block( +async fn reject_follow_request( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -1858,16 +2482,13 @@ async fn block( ) -> AppResult { let user = require_active_user(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; - social::block(&state.pool, user.id, id) + social::reject_follow_request(&state.pool, user.id, id) .await - .map_err(|err| { - tracing::warn!(blocker_id = user.id, blocked_id = id, error = %err, "block rejected"); - AppError::BadRequest(err.to_string()) - })?; - Ok(Redirect::to(&account_action_return(&state.pool, &headers, id).await?).into_response()) + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/follow-requests").into_response()) } -async fn unblock( +async fn cancel_follow_request( State(state): State>, headers: HeaderMap, Path(id): Path, @@ -1875,20 +2496,142 @@ async fn unblock( ) -> AppResult { let user = require_active_user(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; - social::unblock(&state.pool, user.id, id).await?; - Ok(Redirect::to("/settings?saved=profile").into_response()) -} - -fn settings_query_notice(saved: Option<&str>) -> Option<(&'static str, &'static str)> { + social::cancel_follow_request(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + if enhanced_request(&headers) { + return Ok(Json(follow_action_response(&state.pool, user.id, id).await?).into_response()); + } + Ok(Redirect::to("/follow-requests").into_response()) +} + +async fn follow_requests( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_active_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let incoming = social::incoming_follow_requests(&state.pool, user.id).await?; + let outgoing = social::outgoing_follow_requests(&state.pool, user.id).await?; + let body = render::follow_requests_page(&incoming, &outgoing, &csrf); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Follow requests", &body).await?, + )) +} + +async fn unfollow( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::unfollow(&state.pool, user.id, id).await?; + if enhanced_request(&headers) { + return Ok(Json(follow_action_response(&state.pool, user.id, id).await?).into_response()); + } + Ok(Redirect::to(&account_action_return(&state.pool, &headers, id).await?).into_response()) +} + +async fn block( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::block(&state.pool, user.id, id) + .await + .map_err(|err| { + tracing::warn!(blocker_id = user.id, blocked_id = id, error = %err, "block rejected"); + AppError::BadRequest(err.to_string()) + })?; + Ok(Redirect::to(&account_action_return(&state.pool, &headers, id).await?).into_response()) +} + +async fn unblock( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::unblock(&state.pool, user.id, id).await?; + Ok(Redirect::to("/settings?saved=profile").into_response()) +} + +fn settings_query_notice(saved: Option<&str>) -> Option<(&'static str, &'static str)> { match saved { Some("profile") => Some(("success", "Profile settings saved.")), Some("muted-word") => Some(("success", "Muted word saved.")), Some("muted-word-removed") => Some(("success", "Muted word removed.")), Some("password") => Some(("success", "Password changed.")), + Some("username") => Some(("success", "Username changed.")), + Some("delete-requested") => Some(( + "success", + "Account deletion requested. You can cancel it until the deadline.", + )), + Some("delete-cancelled") => Some(("success", "Account deletion cancelled.")), _ => None, } } +/// Username change form plus the account's recorded previous handles. +fn settings_username_panel( + username: &str, + history: &[identity::UsernameHistoryEntry], + csrf: &str, +) -> String { + let history_html = if history.is_empty() { + r#"

    No previous usernames.

    "#.to_owned() + } else { + let rows = history + .iter() + .map(|entry| { + format!( + r#"
  • @{} changed {}
  • "#, + html_escape::encode_text(&entry.username), + html_escape::encode_text(&entry.changed_at) + ) + }) + .collect::>() + .join(""); + format!( + r#"

    Previous usernames

    Handles you used before stay reserved so nobody else can claim them. You can always switch back to one of them.

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

    Username

    Your public @handle is used in profile links and mentions.

    {history_html}
    "#, + csrf = html_escape::encode_double_quoted_attribute(csrf), + username = html_escape::encode_double_quoted_attribute(username), + ) +} + +/// Export and import entry points for portable account archives. +fn settings_account_data_panel() -> String { + r#"

    Account data

    Export your posts, profile, media, follows, and settings as a portable archive, or import an archive exported from another RustPost instance.

    Export account archive Import account archive

    Archives never include your password, sessions, tokens, or administrator access.

    "# + .to_owned() +} + +/// Danger panel for requesting or cancelling account deletion. +fn settings_delete_panel(deletion: Option<&account::DeletionRequest>, csrf: &str) -> String { + deletion.map_or_else( + || { + r#"

    Delete account

    Deleting your account starts a countdown before permanent removal.

    Start delete account flow

    "#.to_owned() + }, + |deletion| { + format!( + r#"

    Delete account

    This account is scheduled for permanent deletion on {}.

    You can cancel the deletion until then. Until it completes, this account keeps your posts but cannot publish or change account data.

    "#, + html_escape::encode_text(&deletion.scheduled_at), + html_escape::encode_double_quoted_attribute(csrf), + ) + }, + ) +} + struct SettingsProfile { display_name: String, bio: String, @@ -1897,16 +2640,18 @@ struct SettingsProfile { theme: String, nsfw_blur_enabled: bool, liked_posts_public: bool, + follow_approval_required: bool, picture_path: Option, banner_path: Option, } async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult { pool.call(move |conn| { - conn.query_row( - r#" + Ok(conn + .query_row( + r#" SELECT u.display_name, u.bio, u.location, u.website, u.theme, u.nsfw_blur_enabled, - u.liked_posts_public, + u.liked_posts_public, u.follow_approval_required, pic.public_path AS profile_picture_path, banner.public_path AS banner_path FROM users u @@ -1914,25 +2659,26 @@ async fn settings_profile(pool: &SqlitePool, user_id: i64) -> AppResult(5)? != 0, - liked_posts_public: row.get::<_, i64>(6)? != 0, - picture_path: row.get(7)?, - banner_path: row.get(8)?, - }) - }, - ) - .map_err(Into::into) + [user_id], + |row| { + Ok(SettingsProfile { + display_name: row.get(0)?, + bio: row.get(1)?, + location: row.get(2)?, + website: row.get(3)?, + theme: row.get(4)?, + nsfw_blur_enabled: row.get::<_, i64>(5)? != 0, + liked_posts_public: row.get::<_, i64>(6)? != 0, + follow_approval_required: row.get::<_, i64>(7)? != 0, + picture_path: row.get(8)?, + banner_path: row.get(9)?, + }) + }, + ) + .optional()?) }) - .await - .map_err(Into::into) + .await? + .ok_or(AppError::Unauthorized) } fn settings_profile_media( @@ -2109,6 +2855,45 @@ fn validate_profile_location(location: &str) -> AppResult<()> { Ok(()) } +fn validate_profile_website(website: &str) -> AppResult<()> { + let website = website.trim(); + if website.is_empty() { + return Ok(()); + } + if website.chars().count() > 2_048 { + return Err(AppError::BadRequest("website URL is too long".to_owned())); + } + if website.chars().any(char::is_control) { + return Err(AppError::BadRequest( + "website URL contains unsupported control characters".to_owned(), + )); + } + if !is_safe_profile_website_url(website) { + return Err(AppError::BadRequest( + "website URL must start with http:// or https://".to_owned(), + )); + } + Ok(()) +} + +fn is_safe_profile_website_url(website: &str) -> bool { + website.parse::().is_ok_and(|uri| { + matches!(uri.scheme_str(), Some("http" | "https")) && uri.authority().is_some() + }) +} + +fn render_profile_website_link(website: &str) -> String { + let website = website.trim(); + if website.is_empty() || !is_safe_profile_website_url(website) { + return String::new(); + } + format!( + r#"

    {}

    "#, + html_escape::encode_double_quoted_attribute(website), + html_escape::encode_text(website) + ) +} + async fn mute( State(state): State>, headers: HeaderMap, @@ -2149,6 +2934,10 @@ async fn settings_form( Ok(Html(settings_page(&state, &user, &csrf, notice).await?)) } +#[expect( + clippy::too_many_lines, + reason = "the settings page template is assembled in one place so panels keep a consistent layout" +)] async fn settings_page( state: &AppState, user: &CurrentUser, @@ -2159,6 +2948,10 @@ async fn settings_page( let blocked = social::blocked_users(&state.pool, user.id).await?; let muted = social::muted_users(&state.pool, user.id).await?; let muted_words = social::muted_words(&state.pool, user.id).await?; + let username_history = identity::username_history(&state.pool, user.id).await?; + let deletion = account::deletion_status(&state.pool, user.id).await?; + let username_panel = settings_username_panel(&user.username, &username_history, csrf); + let delete_panel = settings_delete_panel(deletion.as_ref(), csrf); let notice_html = notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); let password_hint = if state.settings.accounts.min_password_length == 0 { @@ -2209,10 +3002,16 @@ async fn settings_page( "Make liked posts public", "Allow other people to view your Likes tab on your profile.", ), + settings_switch( + "follow_approval_required", + profile.follow_approval_required, + "Require approval for new followers", + "New followers must be approved before they can follow you. Existing followers are kept, and pending requests are not affected.", + ), ] .join(""); let body = format!( - r#"{notice_html}

    Account settings

    Profile, privacy, media, and account controls.

    Profile media

    Avatar and banner images for your profile header.

    {}

    Profile

    Your name, bio, location, and link as shown on your profile.

    {}

    Preferences and privacy

    Control your display theme, NSFW media blur, and profile activity visibility.

    {}

    Blocked users

    Blocked accounts cannot follow or interact with you.

    {}

    Muted users

    Muted accounts stay hidden from your views.

    {}

    Muted words

    Hide posts containing specific words or phrases.

    {}

    Change password

    Update the password used to sign in to this account.

    {}

    {}

    Delete account

    This permanently removes your profile, posts, media, sessions, and account relationships.

    Start delete account flow

    "#, + r#"{notice_html}

    Account settings

    Profile, privacy, media, and account controls.

    Profile media

    Avatar and banner images for your profile header.

    {}

    Profile

    Your name, bio, location, and link as shown on your profile.

    {}

    Preferences and privacy

    Control your display theme, NSFW media blur, and profile activity visibility.

    {}

    Blocked users

    Blocked accounts cannot follow or interact with you.

    {}

    Muted users

    Muted accounts stay hidden from your views.

    {}

    Muted words

    Hide posts containing specific words or phrases.

    {}

    Change password

    Update the password used to sign in to this account.

    {}

    {}

    {username_panel}{account_data_panel}{delete_panel}"#, html_escape::encode_double_quoted_attribute(&csrf), profile_media, profile_fields, @@ -2240,6 +3039,9 @@ async fn settings_page( new_password_attrs, html_escape::encode_text(&password_hint), confirm_new_password_attrs, + username_panel = username_panel, + account_data_panel = settings_account_data_panel(), + delete_panel = delete_panel, ); page_layout(state, Some(user), Some(csrf), "Settings", &body).await } @@ -2263,9 +3065,23 @@ async fn settings_update( ) -> AppResult { let user = require_user(&state, &headers).await?; let form = parse_profile_update(&state, user.id, multipart).await?; - validate_csrf(&state.pool, &headers, &form.csrf_token).await?; + let outcome = apply_profile_update(&state, &headers, &user, &form).await; + if outcome.is_err() { + cleanup_profile_uploads(&state, &form).await; + } + outcome +} + +async fn apply_profile_update( + state: &AppState, + headers: &HeaderMap, + user: &CurrentUser, + form: &ParsedProfileUpdate, +) -> AppResult { + validate_csrf(&state.pool, headers, &form.csrf_token).await?; crate::validation::validate_profile_text(&form.display_name, &form.bio, &state.settings)?; validate_profile_location(&form.location)?; + validate_profile_website(&form.website)?; let display_name = form.display_name.trim().to_owned(); let bio = form.bio.trim().to_owned(); let location = form.location.trim().to_owned(); @@ -2273,11 +3089,13 @@ async fn settings_update( let theme = form.theme.as_str().to_owned(); let nsfw_blur_enabled = i64::from(form.nsfw_blur_enabled); let liked_posts_public = i64::from(form.liked_posts_public); + let follow_approval_required = i64::from(form.follow_approval_required); + let user_id = user.id; state .pool .call(move |conn| { conn.execute( - "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, theme = ?, nsfw_blur_enabled = ?, liked_posts_public = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", + "UPDATE users SET display_name = ?, bio = ?, location = ?, website = ?, theme = ?, nsfw_blur_enabled = ?, liked_posts_public = ?, follow_approval_required = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?", params![ display_name, bio, @@ -2286,21 +3104,35 @@ async fn settings_update( theme, nsfw_blur_enabled, liked_posts_public, - user.id + follow_approval_required, + user_id ], )?; Ok(()) }) .await?; if form.delete_profile_picture { - media::clear_profile_media(&state.pool, user.id, media::ProfileMediaSlot::Picture).await?; + media::clear_profile_media( + &state.pool, + &state.paths, + user.id, + media::ProfileMediaSlot::Picture, + ) + .await?; } if form.delete_banner { - media::clear_profile_media(&state.pool, user.id, media::ProfileMediaSlot::Banner).await?; + media::clear_profile_media( + &state.pool, + &state.paths, + user.id, + media::ProfileMediaSlot::Banner, + ) + .await?; } if let Some(media_id) = form.profile_picture_media_id { media::set_profile_media( &state.pool, + &state.paths, user.id, media::ProfileMediaSlot::Picture, media_id, @@ -2310,6 +3142,7 @@ async fn settings_update( if let Some(media_id) = form.banner_media_id { media::set_profile_media( &state.pool, + &state.paths, user.id, media::ProfileMediaSlot::Banner, media_id, @@ -2361,6 +3194,7 @@ async fn change_password( ) -> AppResult { let user = require_active_user(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; + let current_session_token = auth::session_cookie(&headers); match auth::change_password( &state.pool, &state.settings, @@ -2368,12 +3202,13 @@ async fn change_password( &form.current_password, &form.new_password, &form.confirm_new_password, + current_session_token.as_deref(), ) .await { Ok(()) => Ok(Redirect::to("/settings?saved=password").into_response()), Err(err) => { - settings_response( + password_response( &state, &user, &headers, @@ -2386,6 +3221,32 @@ async fn change_password( } } +/// Renders password-change failures on the restricted page while a forced +/// reset is active, and on the full settings page otherwise. +async fn password_response( + state: &AppState, + user: &CurrentUser, + headers: &HeaderMap, + status: StatusCode, + kind: &'static str, + message: &str, +) -> AppResult { + if user.must_change_password { + let csrf = form_csrf(state, headers).await.unwrap_or_default(); + return Ok(( + status, + Html(password_change_page_html( + state, + user, + &csrf, + Some((kind, message)), + )), + ) + .into_response()); + } + settings_response(state, user, headers, status, kind, message).await +} + async fn settings_response( state: &AppState, user: &CurrentUser, @@ -2408,7 +3269,11 @@ async fn delete_account_warning( ) -> AppResult> { let user = require_active_user(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let body = render_delete_account_warning(); + let deletion = account::deletion_status(&state.pool, user.id).await?; + let body = deletion.map_or_else( + || render_delete_account_warning(state.settings.accounts.deletion_grace_period_days), + |deletion| render_delete_account_pending(&deletion, &csrf), + ); Ok(Html( page_layout(&state, Some(&user), Some(&csrf), "Delete account", &body).await?, )) @@ -2458,6 +3323,33 @@ async fn delete_account_final( ) .await; } + if state.settings.accounts.deletion_grace_period_days > 0 { + let password_ok = auth::verify_user_password(&state.pool, user.id, &form.password).await?; + if !password_ok { + return delete_account_final_response( + &state, + &user, + &headers, + StatusCode::UNAUTHORIZED, + "Password is incorrect.", + ) + .await; + } + return match account::request_deletion(&state.pool, &state.settings, user.id).await { + Ok(_request) => Ok(Redirect::to("/settings?saved=delete-requested").into_response()), + Err(err) => { + tracing::warn!(user_id = user.id, error = %err, "account deletion request failed"); + delete_account_final_response( + &state, + &user, + &headers, + StatusCode::BAD_REQUEST, + "Account deletion could not be scheduled. Try again later.", + ) + .await + } + }; + } match account::delete_account(&state.pool, &state.paths, user.id, &form.password).await { Ok(_summary) => { let mut response = Redirect::to("/account-deleted").into_response(); @@ -2596,9 +3488,28 @@ async fn account_deleted(State(state): State>) -> AppResult String { - r#""# - .to_owned() +fn render_delete_account_warning(grace_days: u64) -> String { + let grace = if grace_days == 0 { + "With the current configuration the account is removed immediately after password confirmation." + .to_owned() + } else { + format!( + "After you confirm with your password, RustPost starts a {grace_days}-day countdown. You can cancel any time before the deadline; permanent removal happens after it." + ) + }; + format!( + r#""#, + html_escape::encode_text(&grace), + ) +} + +fn render_delete_account_pending(deletion: &account::DeletionRequest, csrf: &str) -> String { + format!( + r#""#, + html_escape::encode_text(&deletion.scheduled_at), + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_text(&deletion.requested_at), + ) } fn render_delete_account_final_warning( @@ -2608,1672 +3519,1501 @@ fn render_delete_account_final_warning( ) -> String { let notice = error.map_or_else(String::new, |message| render::notice("error", message)); format!( - r#"{notice}"#, + r#"{notice}"#, html_escape::encode_double_quoted_attribute(csrf), html_escape::encode_double_quoted_attribute(delete_intent) ) } -// Multipart parsing is kept in one place so uploaded profile media and text -// fields share one validation path before any database updates happen. -#[expect( - clippy::too_many_lines, - reason = "multipart profile parsing keeps validation and file handling in one transaction-sized flow" -)] -async fn parse_profile_update( +/// Standalone password form used for the forced-password-reset flow and +/// direct visits to `/settings/password`. +async fn password_change_page( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let user = require_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let notice = if query.required.as_deref() == Some("1") { + Some(( + "error", + "You must change your password before using the rest of your account.", + )) + } else { + settings_query_notice(query.saved.as_deref()) + }; + Ok(Html(password_change_page_html( + &state, &user, &csrf, notice, + ))) +} + +fn password_change_page_html( state: &AppState, - user_id: i64, - mut multipart: Multipart, -) -> AppResult { - let mut form = ParsedProfileUpdate { - csrf_token: String::new(), - display_name: String::new(), - bio: String::new(), - location: String::new(), - website: String::new(), - theme: Theme::Light, - delete_profile_picture: false, - delete_banner: false, - nsfw_blur_enabled: false, - liked_posts_public: false, - profile_picture_media_id: None, - banner_media_id: None, + user: &CurrentUser, + csrf: &str, + notice: Option<(&str, &str)>, +) -> String { + let notice_html = + notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let hint = password_hint(state); + let new_password_attrs = render::password_length_attrs( + state.settings.accounts.min_password_length, + "new-password-requirement", + ); + let confirm_new_password_attrs = render::password_length_attrs( + state.settings.accounts.min_password_length, + "confirm-new-password-requirement", + ); + let logout = small_form("/logout", csrf, "Log out", "Log out of this session"); + let restricted = if user.must_change_password { + r#"

    An administrator requires this account to set a new password before it can be used again.

    "# + } else { + "" }; - while let Some(field) = multipart - .next_field() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))? - { - let Some(name) = field.name().map(ToOwned::to_owned) else { - continue; - }; - match name.as_str() { - "csrf" => { - form.csrf_token = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "display_name" => { - form.display_name = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "bio" => { - form.bio = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "location" => { - form.location = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "website" => { - form.website = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "dark_mode" => { - form.theme = Theme::Dark; - } - "nsfw_blur_enabled" => { - form.nsfw_blur_enabled = true; - } - "liked_posts_public" => { - form.liked_posts_public = true; - } - "delete_profile_picture" => { - form.delete_profile_picture = true; - } - "delete_banner" => { - form.delete_banner = true; - } - "profile_picture" if field.file_name().is_some() => { - if !state.settings.accounts.allow_profile_pictures { - return Err(AppError::Forbidden); - } - if field.file_name().is_none_or(|name| name.trim().is_empty()) { - continue; - } - form.profile_picture_media_id = Some( - media::save_profile_picture_upload( - &state.pool, - &state.settings, - &state.paths, - &state.ffmpeg, - user_id, - field, - ) - .await - .map_err(|err| { - tracing::warn!(error = %err, "profile picture upload rejected"); - AppError::BadRequest(err.to_string()) - })?, - ); - } - "banner" if field.file_name().is_some() => { - if !state.settings.accounts.allow_profile_banners { - return Err(AppError::Forbidden); - } - if field.file_name().is_none_or(|name| name.trim().is_empty()) { - continue; - } - form.banner_media_id = Some( - media::save_upload( - &state.pool, - &state.settings, - &state.paths, - &state.ffmpeg, - Some(user_id), - field, - ) - .await - .map_err(|err| { - tracing::warn!(error = %err, "profile banner upload rejected"); - AppError::BadRequest(err.to_string()) - })?, - ); - } - _ => {} - } - } - Ok(form) + format!( + r#"{notice_html}

    Change password

    Update the password used to sign in to this account.

    {restricted}{}
    {logout}
    "#, + password_form_html( + csrf, + &hint, + &new_password_attrs, + &confirm_new_password_attrs + ), + ) } -async fn ensure_parent_post_exists(pool: &SqlitePool, parent_id: i64) -> AppResult<()> { - let exists = pool - .call(move |conn| { - Ok(conn - .query_row( - "SELECT 1 FROM posts WHERE id = ? AND is_deleted = 0", - [parent_id], - |_| Ok(()), - ) - .optional()? - .is_some()) - }) - .await?; - if exists { - Ok(()) +fn password_hint(state: &AppState) -> String { + if state.settings.accounts.min_password_length == 0 { + "No minimum password length is currently required.".to_owned() } else { - Err(AppError::BadRequest( - "reply target was not found; it may have been deleted".to_owned(), - )) + format!( + "Password must be at least {} characters.", + state.settings.accounts.min_password_length + ) } } -async fn delete_preview( - pool: &SqlitePool, - actor_id: i64, - is_admin: bool, - post_id: i64, -) -> AppResult { - let preview = pool - .call(move |conn| { - conn.query_row( - r#" - SELECT p.user_id, p.text, u.username, u.display_name, p.parent_post_id - FROM posts p - LEFT JOIN users u ON u.id = p.user_id - WHERE p.id = ? AND p.is_deleted = 0 - "#, - [post_id], - |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, Option>(3)?, - row.get::<_, Option>(4)?, - )) - }, - ) - .optional() - .map_err(Into::into) - }) - .await?; - let Some((owner, text, username, display_name, parent_post_id)) = preview else { - return Err(AppError::NotFound); - }; - if !is_admin && owner != Some(actor_id) { - return Err(AppError::Forbidden); - } - Ok(DeletePreview { - text, - username, - display_name, - parent_post_id, - }) +fn password_form_html( + csrf: &str, + hint: &str, + new_password_attrs: &str, + confirm_new_password_attrs: &str, +) -> String { + format!( + r#"

    {}

    {}

    "#, + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_text(hint), + new_password_attrs, + html_escape::encode_text(hint), + confirm_new_password_attrs, + ) } -async fn edit_preview( - pool: &SqlitePool, - actor_id: i64, - post_id: i64, - edit_window_seconds: u64, -) -> AppResult { - let edit_window_modifier = edit_window_modifier(edit_window_seconds); - let preview = pool - .call(move |conn| { - conn.query_row( - r#" - SELECT p.user_id, p.text, p.parent_post_id, - CASE - WHEN ? IS NULL THEN 0 - ELSE p.created_at >= datetime('now', ?) - END AS within_window - FROM posts p - WHERE p.id = ? AND p.is_deleted = 0 - "#, - params![edit_window_modifier.clone(), edit_window_modifier, post_id], - |row| { - Ok(( - row.get::<_, Option>(0)?, - row.get::<_, String>(1)?, - row.get::<_, Option>(2)?, - row.get::<_, i64>(3)? != 0, - )) - }, - ) - .optional() - .map_err(Into::into) - }) - .await?; - let Some((owner, text, parent_post_id, within_window)) = preview else { - return Err(AppError::NotFound); - }; - if owner != Some(actor_id) { - return Err(AppError::Forbidden); - } - if !within_window { - return Err(AppError::BadRequest( - "the edit window for this post has expired".to_owned(), - )); - } - Ok(EditPreview { - text, - parent_post_id, - }) +#[derive(Deserialize)] +struct UsernameChangeForm { + csrf: String, + new_username: String, + password: String, } -fn edit_window_modifier(seconds: u64) -> Option { - if seconds == 0 { - return None; +async fn change_username( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let password_ok = auth::verify_user_password(&state.pool, user.id, &form.password).await?; + if !password_ok { + return settings_response( + &state, + &user, + &headers, + StatusCode::UNAUTHORIZED, + "error", + "Password is incorrect.", + ) + .await; } - i64::try_from(seconds) - .ok() - .map(|seconds| format!("-{seconds} seconds")) -} - -async fn post_is_reply(pool: &SqlitePool, post_id: i64) -> AppResult { - Ok(pool - .call(move |conn| { - conn.query_row( - "SELECT parent_post_id IS NOT NULL FROM posts WHERE id = ? AND is_deleted = 0", - [post_id], - |row| row.get::<_, i64>(0), + match identity::change_username(&state.pool, &state.settings, user.id, &form.new_username).await + { + Ok(_change) => Ok(Redirect::to("/settings?saved=username").into_response()), + Err(err) => { + settings_response( + &state, + &user, + &headers, + StatusCode::BAD_REQUEST, + "error", + &err.to_string(), ) - .optional() - .map(|value| value.unwrap_or(0) != 0) - .map_err(Into::into) - }) - .await?) -} - -fn redirect_to_post_anchor(headers: &HeaderMap, post_id: i64, is_reply: bool) -> Redirect { - let target = anchored_return(headers, post_id, is_reply, "/home"); - Redirect::to(&target) + .await + } + } } -fn enhanced_request(headers: &HeaderMap) -> bool { - headers - .get("x-rustpost-enhance") - .and_then(|value| value.to_str().ok()) - .is_some_and(|value| value == "1") +async fn cancel_deletion( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + account::cancel_deletion(&state.pool, user.id).await?; + Ok(Redirect::to("/settings?saved=delete-cancelled").into_response()) } -async fn follow_action_response( - pool: &SqlitePool, - viewer_id: i64, - profile_id: i64, -) -> AppResult { - let following = social::is_following(pool, viewer_id, profile_id).await?; - let (followers, following_count) = social::follow_counts(pool, profile_id).await?; - Ok(FollowActionResponse { - kind: "follow", - user_id: profile_id, - following, - followers, - following_count, - action: if following { - format!("/users/{profile_id}/unfollow") - } else { - format!("/users/{profile_id}/follow") - }, - }) +async fn export_account( + State(state): State>, + headers: HeaderMap, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + // The guard keeps runtime temp cleanup from removing the staged export + // archive while it is being written. + let _operation = crate::runtime::begin_temp_operation(); + let filename = format!( + "rustpost-account-{}-{}.tar.gz", + safe_filename_component(&user.username), + Uuid::new_v4().simple() + ); + let destination = + state + .paths + .tmp_dir + .join(format!("{}{}", portability::EXPORT_TMP_PREFIX, filename)); + let export = match portability::export_account( + &state.pool, + &state.paths, + user.id, + &destination, + portability::ArchiveLimits::from_settings(&state.settings), + ) + .await + { + Ok(export) => export, + Err(err) => { + tracing::warn!(user_id = user.id, error = %err, "account export failed"); + let _ = tokio::fs::remove_file(&destination).await; + return Err(AppError::BadRequest(err.to_string())); + } + }; + stream_staged_file(&export.archive_path, &filename, "application/gzip").await } -async fn post_action_response( - pool: &SqlitePool, - viewer_id: i64, - post_id: i64, -) -> AppResult { - let state = pool - .call(move |conn| { - conn.query_row( - r#" - SELECT - ( - SELECT COUNT(*) - FROM likes l - JOIN users u ON u.id = l.user_id AND u.is_deleted = 0 - WHERE l.post_id = p.id AND (u.liked_posts_public != 0 OR l.user_id = ?) - ), - ((SELECT COUNT(*) FROM reposts WHERE post_id = p.id) + - (SELECT COUNT(*) FROM posts qp WHERE qp.quote_post_id = p.id AND qp.is_deleted = 0)), - (SELECT COUNT(*) FROM posts replies WHERE replies.parent_post_id = p.id AND replies.is_deleted = 0), - EXISTS(SELECT 1 FROM likes WHERE user_id = ? AND post_id = p.id), - EXISTS(SELECT 1 FROM bookmarks WHERE user_id = ? AND post_id = p.id), - EXISTS(SELECT 1 FROM reposts WHERE user_id = ? AND post_id = p.id) - FROM posts p - WHERE p.id = ? AND p.is_deleted = 0 - "#, - params![viewer_id, viewer_id, viewer_id, viewer_id, post_id], - |row| { - Ok(PostActionResponse { - kind: "post-action", - post_id, - likes: row.get(0)?, - reposts: row.get(1)?, - replies: row.get(2)?, - liked: row.get::<_, i64>(3)? != 0, - bookmarked: row.get::<_, i64>(4)? != 0, - reposted: row.get::<_, i64>(5)? != 0, - }) - }, - ) - .optional() - .map_err(Into::into) - }) - .await?; - state.ok_or(AppError::NotFound) +/// Validates a value for use inside an attachment filename. +fn safe_filename_component(value: &str) -> String { + value + .chars() + .filter(|character| character.is_ascii_alphanumeric() || matches!(character, '-' | '_')) + .take(32) + .collect() } -async fn account_action_return( - pool: &SqlitePool, - headers: &HeaderMap, - profile_id: i64, -) -> AppResult { - if let Some(target) = referer_target(headers) - .as_deref() - .and_then(safe_return_target) - { - return Ok(target); - } - Ok(user_profile_path(pool, profile_id) - .await? - .unwrap_or_else(|| "/home".to_owned())) +async fn stream_staged_file( + path: &std::path::Path, + download_name: &str, + content_type: &'static str, +) -> AppResult { + let metadata = tokio::fs::metadata(path).await?; + let file = tokio::fs::File::open(path).await?; + let cleanup_path = path.to_owned(); + let stream = futures_util::stream::unfold( + (file, cleanup_path), + |(mut file, cleanup_path)| async move { + let mut buffer = vec![0u8; 64 * 1024]; + match file.read(&mut buffer).await { + Ok(0) => { + let _ = tokio::fs::remove_file(&cleanup_path).await; + None + } + Ok(read) => { + buffer.truncate(read); + Some(( + Ok::(Bytes::from(buffer)), + (file, cleanup_path), + )) + } + Err(error) => { + let _ = tokio::fs::remove_file(&cleanup_path).await; + Some((Err(error), (file, cleanup_path))) + } + } + }, + ); + let disposition = format!( + "attachment; filename=\"{}\"", + download_name.replace('"', "") + ); + let mut response = Body::from_stream(stream).into_response(); + response + .headers_mut() + .insert(header::CONTENT_TYPE, HeaderValue::from_static(content_type)); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&metadata.len().to_string()) + .map_err(|err| AppError::BadRequest(err.to_string()))?, + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&disposition).map_err(|err| AppError::BadRequest(err.to_string()))?, + ); + Ok(response) } -async fn user_profile_path(pool: &SqlitePool, user_id: i64) -> AppResult> { - Ok(pool - .call(move |conn| { - conn.query_row( - "SELECT username FROM users WHERE id = ? AND is_deleted = 0", - [user_id], - |row| row.get::<_, String>(0), +async fn import_account_form( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_active_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let instance = instance::load(&state.pool).await?; + let body = if instance.maintenance_mode { + format!( + r#"

    Import account archive

    {}
    "#, + render::notice( + "error", + instance + .maintenance_notice() + .unwrap_or(instance::DEFAULT_MAINTENANCE_MESSAGE) ) - .optional() - .map_err(Into::into) - }) - .await? - .map(|username| format!("/users/{username}"))) -} - -fn anchored_return(headers: &HeaderMap, post_id: i64, is_reply: bool, fallback: &str) -> String { - let anchor = if is_reply { - format!("reply-{post_id}") + ) } else { - format!("post-{post_id}") + render_import_account_form( + &csrf, + user.must_change_password, + state.settings.accounts.max_archive_upload_bytes, + state.settings.accounts.max_archive_expanded_bytes, + ) }; - let base = referer_target(headers) - .as_deref() - .and_then(safe_return_target) - .unwrap_or_else(|| fallback.to_owned()); - let base = base.split('#').next().unwrap_or(fallback); - format!("{base}#{anchor}") + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Import account", &body).await?, + )) } -fn delete_return_fallback( - headers: &HeaderMap, - post_id: i64, - parent_post_id: Option, +fn render_import_account_form( + csrf: &str, + forced_password_change: bool, + max_upload_bytes: u64, + max_expanded_bytes: u64, ) -> String { - let fallback = anchored_return(headers, post_id, parent_post_id.is_some(), "/home"); - if safe_delete_return_target(&fallback, post_id).is_some() { - return fallback; - } - parent_post_id.map_or_else( - || format!("/home#post-{post_id}"), - |parent_id| format!("/posts/{parent_id}#post-{parent_id}"), + let disabled = if forced_password_change { + r#"

    Finish the required password change before importing an archive.

    "# + } else { + "" + }; + format!( + r#"

    Import account archive

    Import a RustPost account archive that you exported from another instance. The archive adds posts, media, muted words, and outgoing follows to your account. Usernames, passwords, administrator flags, and sessions in an archive are ignored.

    {}{disabled}

    Maximum compressed archive size: {}. Maximum expanded media size: {}. Limits are enforced while the archive is streamed.

    Back to settings

    "#, + render::notice( + "info", + "Follows to protected accounts become pending requests and still require approval.", + ), + html_escape::encode_double_quoted_attribute(csrf), + format_bytes(max_upload_bytes), + format_bytes(max_expanded_bytes), ) } -fn edit_return_fallback(headers: &HeaderMap, post_id: i64, parent_post_id: Option) -> String { - let fallback = anchored_return(headers, post_id, parent_post_id.is_some(), "/home"); - if safe_edit_return_target(&fallback, post_id).is_some() { - return fallback; - } - parent_post_id.map_or_else( - || format!("/posts/{post_id}"), - |parent_id| format!("/posts/{parent_id}#reply-{post_id}"), +async fn import_account( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> AppResult { + let user = require_active_user(&state, &headers).await?; + // The guard keeps runtime temp cleanup from removing this operation's + // staged upload or export archive while it is in flight. + let _operation = crate::runtime::begin_temp_operation(); + // Imports publish content, so they share the post rate-limit budget. + rate_limit::check_and_record( + &state.pool, + rate_limit::Scope::Post, + &user_actor(user.id), + state.settings.moderation.posts_per_minute, + 60, ) -} - -fn safe_edit_return_target(value: &str, post_id: i64) -> Option { - let target = safe_return_target(value)?; - let self_path = format!("/posts/{post_id}/edit"); - if path_without_query_or_fragment(&target) == self_path { - None - } else { - Some(target) - } -} - -fn safe_delete_return_target(value: &str, post_id: i64) -> Option { - let target = safe_return_target(value)?; - let self_path = format!("/posts/{post_id}/delete"); - let thread_path = format!("/posts/{post_id}"); - let path = path_without_query_or_fragment(&target); - if path == self_path || path == thread_path { - None - } else { - Some(target) + .await + .map_err(|err| AppError::RateLimited(err.to_string()))?; + let staged = stage_account_import(&state, &headers, multipart).await?; + let report = + portability::import_account(&state.pool, &state.paths, &state.settings, user.id, &staged) + .await; + let _ = tokio::fs::remove_file(&staged).await; + match report { + Ok(report) => { + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let body = render_import_report(&report, &csrf); + Ok( + Html( + page_layout(&state, Some(&user), Some(&csrf), "Import complete", &body).await?, + ) + .into_response(), + ) + } + Err(err) => { + tracing::warn!(user_id = user.id, error = %err, "account import failed"); + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let body = format!( + r#"{}{}"#, + render::notice("error", &err.to_string()), + render_import_account_form( + &csrf, + user.must_change_password, + state.settings.accounts.max_archive_upload_bytes, + state.settings.accounts.max_archive_expanded_bytes, + ) + ); + Ok(( + StatusCode::BAD_REQUEST, + Html(page_layout(&state, Some(&user), Some(&csrf), "Import account", &body).await?), + ) + .into_response()) + } } } -fn parse_notification_ids(value: &str) -> AppResult> { - if value.len() > 1024 { - return Err(AppError::BadRequest( - "notification group is too large".to_owned(), - )); - } - let mut ids = Vec::new(); - for part in value.split(',') { - let trimmed = part.trim(); - if trimmed.is_empty() { +async fn stage_account_import( + state: &AppState, + headers: &HeaderMap, + mut multipart: Multipart, +) -> AppResult { + /// Smallest accepted `csrf` field; larger values are rejected so a + /// malicious request cannot make the server buffer an arbitrary string. + const MAX_CSRF_FIELD_BYTES: u64 = 4 * 1024; + /// Bound for unrelated multipart fields that are drained and ignored. + const MAX_IGNORED_FIELD_BYTES: u64 = 64 * 1024; + + let mut csrf: Option = None; + let mut staged: Option = None; + while let Some(mut field) = multipart + .next_field() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + let name = field.name().map(ToOwned::to_owned).unwrap_or_default(); + if name == "csrf" { + csrf = Some(read_limited_text_field(&mut field, MAX_CSRF_FIELD_BYTES).await?); continue; } - let id = trimmed - .parse::() - .map_err(|_err| AppError::BadRequest("notification group is invalid".to_owned()))?; - if id <= 0 { - return Err(AppError::BadRequest( - "notification group is invalid".to_owned(), - )); - } - if !ids.contains(&id) { - ids.push(id); + if name != "archive" || field.file_name().is_none() { + drain_limited_field(&mut field, MAX_IGNORED_FIELD_BYTES).await?; + continue; } - if ids.len() > 80 { + let csrf_token = csrf.clone().ok_or(AppError::Forbidden)?; + validate_csrf(&state.pool, headers, &csrf_token).await?; + if staged.is_some() { return Err(AppError::BadRequest( - "notification group is too large".to_owned(), + "only one archive can be imported at a time".to_owned(), )); } - } - if ids.is_empty() { - return Err(AppError::BadRequest( - "notification group is invalid".to_owned(), + let path = state.paths.tmp_dir.join(format!( + "{}{}.tar.gz", + portability::IMPORT_TMP_PREFIX, + Uuid::new_v4().simple() )); + let written = write_multipart_field_to_file( + field, + &path, + Some(state.settings.accounts.max_archive_upload_bytes), + ) + .await; + match written { + Ok(_bytes) => staged = Some(path), + Err(err) => { + let _ = tokio::fs::remove_file(&path).await; + return Err(err); + } + } } - Ok(ids) + staged.ok_or_else(|| AppError::BadRequest("choose a RustPost account archive".to_owned())) } -fn parse_notification_group_target(value: Option<&str>) -> AppResult> { - let Some(value) = value else { - return Ok(None); - }; - let trimmed = value.trim(); - if trimmed.is_empty() { - return Ok(None); +/// Reads a text multipart field with a byte cap so an oversized field cannot +/// force unbounded buffering. +async fn read_limited_text_field( + field: &mut axum::extract::multipart::Field<'_>, + max_bytes: u64, +) -> AppResult { + let mut bytes = Vec::new(); + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + if u64::try_from(bytes.len() + chunk.len()).unwrap_or(u64::MAX) > max_bytes { + return Err(AppError::BadRequest( + "a multipart form field is too large".to_owned(), + )); + } + bytes.extend_from_slice(&chunk); } - let target = trimmed - .parse::() - .map_err(|_err| AppError::BadRequest("notification group target is invalid".to_owned()))?; - if target <= 0 { - return Err(AppError::BadRequest( - "notification group target is invalid".to_owned(), - )); + String::from_utf8(bytes).map_err(|_utf8| AppError::BadRequest("invalid form value".to_owned())) +} + +/// Drains an ignored multipart field while enforcing a small cap. +async fn drain_limited_field( + field: &mut axum::extract::multipart::Field<'_>, + max_bytes: u64, +) -> AppResult<()> { + let mut read = 0u64; + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + read = read.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + if read > max_bytes { + return Err(AppError::BadRequest( + "a multipart form field is too large".to_owned(), + )); + } } - Ok(Some(target)) + Ok(()) } -fn path_without_query_or_fragment(target: &str) -> &str { - target.split(['?', '#']).next().unwrap_or_default() +fn render_import_report(report: &portability::ImportReport, csrf: &str) -> String { + format!( + r#"

    Import complete

    Archive account
    @{}
    Posts imported
    {}
    Media imported
    {}
    Follows added
    {}
    Follow requests pending approval
    {}
    Follows skipped
    {}
    Post references unresolved
    {}
    Muted words imported
    {}
    Profile fields applied
    {}
    Profile fields kept
    {}

    Back to settings

    {}
    "#, + html_escape::encode_text(&report.archive_username), + report.posts_imported, + report.media_imported, + report.follows_imported, + report.follows_pending, + report.follows_skipped, + report.post_references_dropped, + report.muted_words_imported, + report.profile_fields_applied, + report.profile_fields_skipped, + small_form( + "/settings/import", + csrf, + "Import another archive", + "Import another account archive" + ), + ) } -fn safe_return_target(value: &str) -> Option { - let target = if value.starts_with('/') && !value.starts_with("//") { - value.to_owned() - } else { - value.parse::().ok().and_then(|uri| { - uri.path_and_query() - .map(|path| path.as_str().to_owned()) - .filter(|path| path.starts_with('/')) - })? +// Multipart parsing is kept in one place so uploaded profile media and text +// fields share one validation path before any database updates happen. +async fn parse_profile_update( + state: &AppState, + user_id: i64, + multipart: Multipart, +) -> AppResult { + let mut form = ParsedProfileUpdate { + csrf_token: String::new(), + display_name: String::new(), + bio: String::new(), + location: String::new(), + website: String::new(), + theme: Theme::Light, + delete_profile_picture: false, + delete_banner: false, + nsfw_blur_enabled: false, + liked_posts_public: false, + follow_approval_required: false, + profile_picture_media_id: None, + banner_media_id: None, }; - let path = target.split('#').next().unwrap_or_default(); - if matches!( - path, - "/home" | "/bookmarks" | "/notifications" | "/search" | "/" - ) || path.starts_with("/posts/") - || path.starts_with("/users/") - || path.starts_with("/tags/") + let outcome = fill_profile_update_form(state, user_id, multipart, &mut form).await; + if outcome.is_err() { + cleanup_profile_uploads(state, &form).await; + } + outcome.map(|()| form) +} + +async fn fill_profile_update_form( + state: &AppState, + user_id: i64, + mut multipart: Multipart, + form: &mut ParsedProfileUpdate, +) -> AppResult<()> { + while let Some(field) = multipart + .next_field() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? { - Some(target) - } else { - None + let Some(name) = field.name().map(ToOwned::to_owned) else { + continue; + }; + match name.as_str() { + "csrf" | "display_name" | "bio" | "location" | "website" => { + fill_profile_text_field(form, &name, field).await?; + } + "dark_mode" => form.theme = Theme::Dark, + "nsfw_blur_enabled" => form.nsfw_blur_enabled = true, + "liked_posts_public" => form.liked_posts_public = true, + "follow_approval_required" => form.follow_approval_required = true, + "delete_profile_picture" => form.delete_profile_picture = true, + "delete_banner" => form.delete_banner = true, + "profile_picture" if field.file_name().is_some() => { + fill_profile_picture_upload(state, user_id, form, field).await?; + } + "banner" if field.file_name().is_some() => { + fill_banner_upload(state, user_id, form, field).await?; + } + _ => {} + } } + Ok(()) } -fn referer_target(headers: &HeaderMap) -> Option { - let value = headers - .get(header::REFERER) - .and_then(|value| value.to_str().ok())?; - if value.starts_with('/') && !value.starts_with("//") { - Some(value.to_owned()) - } else { - let uri = value.parse::().ok()?; - uri.path_and_query() - .map(|path| path.as_str().to_owned()) - .filter(|path| path.starts_with('/')) +/// Reads one of the plain text profile fields into the parsed form. +async fn fill_profile_text_field( + form: &mut ParsedProfileUpdate, + name: &str, + field: axum::extract::multipart::Field<'_>, +) -> AppResult<()> { + let text = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + match name { + "csrf" => form.csrf_token = text, + "display_name" => form.display_name = text, + "bio" => form.bio = text, + "location" => form.location = text, + "website" => form.website = text, + _ => {} } + Ok(()) } -async fn bookmarks( - State(state): State>, - headers: HeaderMap, -) -> AppResult> { - let user = require_user(&state, &headers).await?; - let posts = social::timeline(&state.pool, Some(user.id), "bookmarks", None).await?; - let csrf = form_csrf(&state, &headers).await; - let body = format!( - "{}{}", - render::page_header("Bookmarks", "Posts you saved for later."), - render::posts_with_controls_empty_state( - &posts, - Some(&user), - csrf.as_deref(), - blur_nsfw_media(&state, Some(&user)), - state.settings.posts.post_edit_window_seconds, - render::EmptyState::new("No bookmarks yet.", "Saved posts will appear here."), +async fn fill_profile_picture_upload( + state: &AppState, + user_id: i64, + form: &mut ParsedProfileUpdate, + field: axum::extract::multipart::Field<'_>, +) -> AppResult<()> { + if !state.settings.accounts.allow_profile_pictures { + return Err(AppError::Forbidden); + } + if field.file_name().is_none_or(|name| name.trim().is_empty()) { + return Ok(()); + } + form.profile_picture_media_id = Some( + media::save_profile_picture_upload( + &state.pool, + &state.settings, + &state.paths, + &state.ffmpeg, + user_id, + field, ) + .await + .map_err(|err| { + tracing::warn!(error = %err, "profile picture upload rejected"); + AppError::BadRequest(err.to_string()) + })?, ); - Ok(Html( - page_layout(&state, Some(&user), csrf.as_deref(), "Bookmarks", &body).await?, - )) + Ok(()) } -async fn following( - State(state): State>, - headers: HeaderMap, -) -> AppResult> { - let user = require_user(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let accounts = social::following_accounts(&state.pool, user.id).await?; - let body = format!( - "{}{}", - render::page_header("Following", "Accounts you follow."), - render::accounts(&accounts, &csrf) +async fn fill_banner_upload( + state: &AppState, + user_id: i64, + form: &mut ParsedProfileUpdate, + field: axum::extract::multipart::Field<'_>, +) -> AppResult<()> { + if !state.settings.accounts.allow_profile_banners { + return Err(AppError::Forbidden); + } + if field.file_name().is_none_or(|name| name.trim().is_empty()) { + return Ok(()); + } + form.banner_media_id = Some( + media::save_banner_upload( + &state.pool, + &state.settings, + &state.paths, + &state.ffmpeg, + user_id, + field, + ) + .await + .map_err(|err| { + tracing::warn!(error = %err, "profile banner upload rejected"); + AppError::BadRequest(err.to_string()) + })?, ); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Following", &body).await?, - )) + Ok(()) } -async fn notifications( - State(state): State>, - headers: HeaderMap, -) -> AppResult> { - let user = require_user(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let items = social::notification_groups(&state.pool, user.id).await?; - let unread_count = social::unread_notification_count(&state.pool, user.id).await?; - let body = render::notifications_page(&items, unread_count, &csrf); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Notifications", &body).await?, - )) +/// Deletes profile media that was uploaded for a settings save that did not +/// complete, so rejected saves do not leave orphaned uploads behind. +async fn cleanup_profile_uploads(state: &AppState, form: &ParsedProfileUpdate) { + for media_id in [form.profile_picture_media_id, form.banner_media_id] + .into_iter() + .flatten() + { + if let Err(error) = media::delete_media(&state.pool, &state.paths, media_id).await { + tracing::warn!( + media_id, + error = %error, + "failed to clean up media uploaded for a rejected settings save" + ); + } + } } -async fn open_notification_group( - State(state): State>, - headers: HeaderMap, - Form(form): Form, -) -> AppResult { - let user = require_user(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - if let Some(kind) = form.group_kind.as_deref() { - let target_post_id = parse_notification_group_target(form.group_target_post_id.as_deref())?; - social::mark_notification_group_read(&state.pool, user.id, kind, target_post_id).await?; +async fn ensure_parent_post_exists(pool: &SqlitePool, parent_id: i64) -> AppResult<()> { + let exists = pool + .call(move |conn| { + Ok(conn + .query_row( + "SELECT 1 FROM posts WHERE id = ? AND is_deleted = 0", + [parent_id], + |_| Ok(()), + ) + .optional()? + .is_some()) + }) + .await?; + if exists { + Ok(()) } else { - let notification_ids = parse_notification_ids(&form.notification_ids)?; - social::mark_notification_ids_read(&state.pool, user.id, ¬ification_ids).await?; + Err(AppError::BadRequest( + "reply target was not found; it may have been deleted".to_owned(), + )) } - let target = safe_return_target(&form.return_to).unwrap_or_else(|| "/notifications".to_owned()); - Ok(Redirect::to(&target).into_response()) -} - -async fn mark_notifications_read( - State(state): State>, - headers: HeaderMap, - Form(form): Form, -) -> AppResult { - let user = require_user(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - social::mark_notifications_read(&state.pool, user.id).await?; - Ok(Redirect::to("/notifications").into_response()) -} - -async fn mention_suggestions( - State(state): State>, - headers: HeaderMap, - Query(query): Query, -) -> AppResult>> { - let user = current(&state, &headers).await?; - let suggestions = social::mention_suggestions( - &state.pool, - user.as_ref().map(|user| user.id), - query.q.as_deref().unwrap_or_default(), - ) - .await?; - Ok(Json( - suggestions - .into_iter() - .map(|suggestion| MentionSuggestionResponse { - username: suggestion.username, - display_name: suggestion.display_name, - }) - .collect(), - )) } -async fn search( - State(state): State>, - headers: HeaderMap, - Query(query): Query, -) -> AppResult> { - let user = current(&state, &headers).await?; - let q = normalize_search_query(query.q.as_deref().unwrap_or_default()); - let (users, posts) = if q.is_empty() { - (Vec::new(), Vec::new()) - } else { - social::search(&state.pool, user.as_ref().map(|u| u.id), &q).await? +async fn delete_preview( + pool: &SqlitePool, + actor_id: i64, + is_admin: bool, + post_id: i64, +) -> AppResult { + let preview = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT p.user_id, p.text, u.username, u.display_name, p.parent_post_id + FROM posts p + LEFT JOIN users u ON u.id = p.user_id + WHERE p.id = ? AND p.is_deleted = 0 + "#, + [post_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, Option>(3)?, + row.get::<_, Option>(4)?, + )) + }, + ) + .optional() + .map_err(Into::into) + }) + .await?; + let Some((owner, text, username, display_name, parent_post_id)) = preview else { + return Err(AppError::NotFound); }; - let csrf = form_csrf(&state, &headers).await; - let body = render::search_page( - &state.settings.site.name, - &q, - &users, - &posts, - user.as_ref(), - csrf.as_deref(), - render::SearchRenderOptions { - blur_nsfw_media: blur_nsfw_media(&state, user.as_ref()), - post_edit_window_seconds: state.settings.posts.post_edit_window_seconds, - }, - ); - Ok(Html( - page_layout(&state, user.as_ref(), csrf.as_deref(), "Search", &body).await?, - )) -} - -fn normalize_search_query(query: &str) -> String { - query.split_whitespace().collect::>().join(" ") -} - -async fn tag( - State(state): State>, - headers: HeaderMap, - Path(tag): Path, -) -> AppResult> { - search( - State(state), - headers, - Query(SearchQuery { - q: Some(format!("#{tag}")), - }), - ) - .await + if !is_admin && owner != Some(actor_id) { + return Err(AppError::Forbidden); + } + Ok(DeletePreview { + text, + username, + display_name, + parent_post_id, + }) } -async fn admin_dashboard( - State(state): State>, - headers: HeaderMap, -) -> AppResult> { - let user = require_admin(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let favicon_asset = favicon::current(&state.paths); - let remove_form = if favicon_asset.is_custom() { - small_form( - "/admin/favicon/remove", - &csrf, - "Remove favicon", - "Reset to the built-in favicon", - ) - } else { - String::new() +async fn edit_preview( + pool: &SqlitePool, + actor_id: i64, + post_id: i64, + edit_window_seconds: u64, +) -> AppResult { + let edit_window_modifier = edit_window_modifier(edit_window_seconds); + let preview = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT p.user_id, p.text, p.parent_post_id, + CASE + WHEN ? IS NULL THEN 0 + ELSE p.created_at >= datetime('now', ?) + END AS within_window + FROM posts p + WHERE p.id = ? AND p.is_deleted = 0 + "#, + params![edit_window_modifier.clone(), edit_window_modifier, post_id], + |row| { + Ok(( + row.get::<_, Option>(0)?, + row.get::<_, String>(1)?, + row.get::<_, Option>(2)?, + row.get::<_, i64>(3)? != 0, + )) + }, + ) + .optional() + .map_err(Into::into) + }) + .await?; + let Some((owner, text, parent_post_id, within_window)) = preview else { + return Err(AppError::NotFound); }; - let favicon_panel = format!( - r#"

    Favicon

    {}

    Current favicon

    Accepted: .ico, .png, .svg. Maximum size: 256 KiB.

    {}
    "#, - html_escape::encode_text(favicon_asset.state_label()), - html_escape::encode_double_quoted_attribute(&csrf), - remove_form - ); - let body = format!( - "{}{}{}", - render::page_header( - "Admin", - "Manage site health, users, media jobs, settings, and backups." - ), - r#"
    Site healthUsersMedia jobsDeep server settingsBackups
    "#, - favicon_panel - ); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Admin", &body).await?, - )) -} - -async fn admin_favicon_upload( - State(state): State>, - headers: HeaderMap, - multipart: Multipart, -) -> AppResult { - require_admin(&state, &headers).await?; - let parsed = parse_favicon_upload(&state, &headers, multipart).await?; - if !parsed.uploaded { + if owner != Some(actor_id) { + return Err(AppError::Forbidden); + } + if !within_window { return Err(AppError::BadRequest( - "choose a .ico, .png, or .svg favicon to upload".to_owned(), + "the edit window for this post has expired".to_owned(), )); } - Ok(Redirect::to("/admin").into_response()) + Ok(EditPreview { + text, + parent_post_id, + }) } -async fn admin_favicon_remove( - State(state): State>, - headers: HeaderMap, - Form(form): Form, -) -> AppResult { - require_admin(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - favicon::reset(&state.paths).await?; - Ok(Redirect::to("/admin").into_response()) +fn edit_window_modifier(seconds: u64) -> Option { + if seconds == 0 { + return None; + } + i64::try_from(seconds) + .ok() + .map(|seconds| format!("-{seconds} seconds")) } -async fn parse_favicon_upload( - state: &AppState, - headers: &HeaderMap, - mut multipart: Multipart, -) -> AppResult { - let mut parsed = ParsedFaviconUpload { uploaded: false }; - let mut csrf_validated = false; - while let Some(field) = multipart - .next_field() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))? - { - let Some(name) = field.name().map(ToOwned::to_owned) else { - continue; - }; - match name.as_str() { - "csrf" => { - let token = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - validate_csrf(&state.pool, headers, &token).await?; - csrf_validated = true; - } - "favicon" if field.file_name().is_some() => { - if field.file_name().is_none_or(|name| name.trim().is_empty()) { - continue; - } - if !csrf_validated { - return Err(AppError::Forbidden); - } - favicon::save_upload(&state.paths, field) - .await - .map_err(|err| { - tracing::warn!(error = %err, "favicon upload rejected"); - AppError::BadRequest(err.to_string()) - })?; - parsed.uploaded = true; - } - _ => {} - } - } - if !csrf_validated { - return Err(AppError::Forbidden); - } - Ok(parsed) +async fn post_is_reply(pool: &SqlitePool, post_id: i64) -> AppResult { + Ok(pool + .call(move |conn| { + conn.query_row( + "SELECT parent_post_id IS NOT NULL FROM posts WHERE id = ? AND is_deleted = 0", + [post_id], + |row| row.get::<_, i64>(0), + ) + .optional() + .map(|value| value.unwrap_or(0) != 0) + .map_err(Into::into) + }) + .await?) } -async fn admin_health( - State(state): State>, - headers: HeaderMap, -) -> AppResult> { - let user = require_admin(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let media_jobs = admin::media_jobs_report(&state.pool).await?; - let jobs = render_media_jobs_report(&media_jobs); - let schema = crate::db::schema_report(&state.pool).await?; - let schema_version = if schema.is_compatible() { - schema - .version() - .map_or_else(|| "unknown".to_owned(), |version| version.to_string()) - } else { - "incompatible".to_owned() - }; - let schema_summary = if schema.is_compatible() { - schema.summary() - } else { - format!("INCOMPATIBLE: {}", schema.summary()) - }; - let onion = state - .tor - .onion_address() - .unwrap_or_else(|| "unavailable".to_owned()); - let tor_error = state.tor.error().unwrap_or_else(|| "none".to_owned()); - let bootstrap = state - .tor - .bootstrap_status() - .unwrap_or_else(|| "unavailable".to_owned()); - let body = format!( - r#"

    Site health

    DB path
    {}
    DB schema version
    {}
    DB diagnostics
    {}
    Upload path
    {}
    Media path
    {}
    Logs path
    {}
    Backup path
    {}
    ffmpeg
    {}
    WebP support
    {}
    VP9 support
    {}
    Tor
    {}
    Tor enabled
    {}
    Tor running
    {}
    Tor bootstrap
    {}
    Tor error
    {}
    Onion address
    {}
    Anonymous mode
    {}
    Registration
    {}

    Recent media jobs

    {}
    "#, - html_escape::encode_text(&state.paths.database_path.display().to_string()), - html_escape::encode_text(&schema_version), - html_escape::encode_text(&schema_summary), - html_escape::encode_text(&state.paths.uploads_originals.display().to_string()), - html_escape::encode_text(&state.paths.uploads_images.display().to_string()), - html_escape::encode_text(&state.paths.logs_dir.display().to_string()), - html_escape::encode_text(&state.paths.backups_dir.display().to_string()), - html_escape::encode_text(&state.ffmpeg.summary()), - state.ffmpeg.supports_webp, - state.ffmpeg.supports_vp9, - html_escape::encode_text(&state.tor.summary()), - state.tor.enabled(), - state.tor.running(), - html_escape::encode_text(&bootstrap), - html_escape::encode_text(&tor_error), - html_escape::encode_text(&onion), - state.settings.accounts.anonymous_mode_enabled, - state.settings.accounts.registration_enabled, - jobs - ); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Site health", &body).await?, - )) -} - -async fn admin_users( - State(state): State>, - headers: HeaderMap, - Query(query): Query, -) -> AppResult> { - let user = require_admin(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let user_query = query.user_q.unwrap_or_default(); - let post_query = query.post_q.unwrap_or_default(); - let search = admin::AdminUserSearch::new(&user_query, &post_query); - let malformed_quotes = search.post_search.malformed_quotes; - let has_filter = search.has_filter(); - let rows = admin::users(&state.pool, search).await?; - let quote_notice = if malformed_quotes { - r#"

    Post search had unmatched quotes, so it was treated as plain keyword search.

    "# - } else { - "" - }; - let list = admin_user_rows(&rows, &csrf, has_filter); - let body = format!( - r#"

    Users

    {}{}
    "#, - html_escape::encode_double_quoted_attribute(&user_query), - html_escape::encode_double_quoted_attribute(&post_query), - quote_notice, - list - ); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Admin users", &body).await?, - )) +fn redirect_to_post_anchor(headers: &HeaderMap, post_id: i64, is_reply: bool) -> Redirect { + let target = anchored_return(headers, post_id, is_reply, "/home"); + Redirect::to(&target) } -fn admin_user_rows(rows: &[admin::AdminUserInvestigation], csrf: &str, searched: bool) -> String { - if rows.is_empty() { - let message = if searched { - "No users matched those filters." - } else { - "No users found." - }; - return render::compact_empty_state_with_class( - "admin-users-empty", - message, - "Try a different username, handle, display name, or post keyword.", - ); - } - - rows.iter() - .map(|row| admin_user_row(row, csrf, searched)) - .collect::>() - .join("") +fn enhanced_request(headers: &HeaderMap) -> bool { + headers + .get("x-rustpost-enhance") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value == "1") } -fn admin_user_row(row: &admin::AdminUserInvestigation, csrf: &str, searched: bool) -> String { - let display_name = if row.display_name.trim().is_empty() { - row.username.as_str() +async fn follow_action_response( + pool: &SqlitePool, + viewer_id: i64, + profile_id: i64, +) -> AppResult { + let relationship = social::profile_relationship(pool, Some(viewer_id), profile_id).await?; + let (followers, following_count) = social::follow_counts(pool, profile_id).await?; + let action = if relationship.following { + format!("/users/{profile_id}/unfollow") + } else if relationship.requested { + format!("/users/{profile_id}/follow/cancel") } else { - row.display_name.as_str() + format!("/users/{profile_id}/follow") }; - let statuses = [ - if row.is_admin { "Admin" } else { "Member" }, - if row.is_suspended { - "Suspended" - } else { - "Active" - }, - if row.is_deleted { - "Deleted" - } else { - "Not deleted" - }, - ] - .iter() - .map(|status| { - format!( - r#"{}"#, - html_escape::encode_text(status) - ) + Ok(FollowActionResponse { + kind: "follow", + user_id: profile_id, + following: relationship.following, + requested: relationship.requested, + followers, + following_count, + action, }) - .collect::>() - .join(""); - let match_labels = admin_user_match_labels(row, searched); - let preview = row - .post_match_preview - .as_ref() - .map_or_else(String::new, |text| { - format!( - r#"

    Post match preview: {}

    "#, - html_escape::encode_text(&short_preview(text)) - ) - }); - let action = small_form( - &format!("/admin/users/{}/suspend", row.id), - csrf, - if row.is_suspended { - "Unsuspend" - } else { - "Suspend" - }, - if row.is_suspended { - "Unsuspend this account" - } else { - "Suspend this account" - }, - ); - format!( - r#"
    {} @{} #{}
    {}
    {}
    Created
    {}
    Updated
    {}
    Last session
    {}
    Last post
    {}
    Total posts
    {}
    Uploaded media
    {}
    Reports on posts
    {}
    Moderation actions
    {}
    Matching posts
    {}
    {}
    "#, - html_escape::encode_double_quoted_attribute(&row.username), - html_escape::encode_text(display_name), - html_escape::encode_text(&row.username), - row.id, - statuses, - match_labels, - html_escape::encode_text(&row.created_at), - html_escape::encode_text(&row.updated_at), - html_escape::encode_text(row.last_session_at.as_deref().unwrap_or("No session")), - html_escape::encode_text(row.last_post_at.as_deref().unwrap_or("No posts")), - row.total_posts, - row.uploaded_media_count, - row.reports_on_posts_count, - row.moderation_action_count, - row.matching_post_count, - preview, - action - ) } -fn admin_user_match_labels(row: &admin::AdminUserInvestigation, searched: bool) -> String { - if !searched { - return String::new(); - } - - let mut labels = Vec::new(); - if row.matched_name { - labels.push("Matched name".to_owned()); - } - if row.matching_post_count > 0 { - labels.push(format!("Matched post content: {}", row.matching_post_count)); - } - if labels.is_empty() { - return String::new(); - } - let labels = labels - .iter() - .map(|label| { - format!( - r#"{}"#, - html_escape::encode_text(label) +async fn post_action_response( + pool: &SqlitePool, + viewer_id: i64, + post_id: i64, +) -> AppResult { + let state = pool + .call(move |conn| { + conn.query_row( + r#" + SELECT + ( + SELECT COUNT(*) + FROM likes l + JOIN users u ON u.id = l.user_id AND u.is_deleted = 0 + WHERE l.post_id = p.id AND (u.liked_posts_public != 0 OR l.user_id = ?) + ), + ((SELECT COUNT(*) FROM reposts WHERE post_id = p.id) + + (SELECT COUNT(*) FROM posts qp WHERE qp.quote_post_id = p.id AND qp.is_deleted = 0)), + (SELECT COUNT(*) FROM posts replies WHERE replies.parent_post_id = p.id AND replies.is_deleted = 0), + EXISTS(SELECT 1 FROM likes WHERE user_id = ? AND post_id = p.id), + EXISTS(SELECT 1 FROM bookmarks WHERE user_id = ? AND post_id = p.id), + EXISTS(SELECT 1 FROM reposts WHERE user_id = ? AND post_id = p.id) + FROM posts p + LEFT JOIN users author ON author.id = p.user_id + WHERE p.id = ? AND p.is_deleted = 0 + AND (p.user_id IS NULL OR (author.is_deleted = 0 AND author.is_suspended = 0)) + AND ( + p.user_id IS NULL + OR p.user_id = ? + OR ( + p.user_id NOT IN (SELECT blocked_id FROM blocks WHERE blocker_id = ?) + AND NOT EXISTS (SELECT 1 FROM blocks WHERE blocker_id = p.user_id AND blocked_id = ?) + ) + ) + "#, + params![ + viewer_id, viewer_id, viewer_id, viewer_id, post_id, viewer_id, viewer_id, + viewer_id + ], + |row| { + Ok(PostActionResponse { + kind: "post-action", + post_id, + likes: row.get(0)?, + reposts: row.get(1)?, + replies: row.get(2)?, + liked: row.get::<_, i64>(3)? != 0, + bookmarked: row.get::<_, i64>(4)? != 0, + reposted: row.get::<_, i64>(5)? != 0, + }) + }, ) + .optional() + .map_err(Into::into) }) - .collect::>() - .join(""); - format!(r#"
    {labels}
    "#) + .await?; + state.ok_or(AppError::NotFound) } -fn short_preview(text: &str) -> String { - const LIMIT: usize = 140; - let trimmed = text.trim(); - let mut preview = trimmed.chars().take(LIMIT).collect::(); - if trimmed.chars().count() > LIMIT { - preview.push_str("..."); +async fn account_action_return( + pool: &SqlitePool, + headers: &HeaderMap, + profile_id: i64, +) -> AppResult { + if let Some(target) = referer_target(headers) + .as_deref() + .and_then(safe_return_target) + { + return Ok(target); } - preview + Ok(user_profile_path(pool, profile_id) + .await? + .unwrap_or_else(|| "/home".to_owned())) } -async fn admin_suspend( - State(state): State>, - headers: HeaderMap, - Path(id): Path, - Form(form): Form, -) -> AppResult { - let user = require_admin(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - let current: i64 = state - .pool +async fn user_profile_path(pool: &SqlitePool, user_id: i64) -> AppResult> { + Ok(pool .call(move |conn| { - Ok( - conn.query_row("SELECT is_suspended FROM users WHERE id = ?", [id], |row| { - row.get(0) - })?, + conn.query_row( + "SELECT username FROM users WHERE id = ? AND is_deleted = 0", + [user_id], + |row| row.get::<_, String>(0), ) + .optional() + .map_err(Into::into) }) - .await?; - admin::set_user_suspended(&state.pool, user.id, id, current == 0).await?; - Ok(Redirect::to("/admin/users").into_response()) + .await? + .map(|username| format!("/users/{username}"))) } -async fn admin_delete_post( - State(state): State>, - headers: HeaderMap, - Path(id): Path, - Form(form): Form, -) -> AppResult { - let user = require_admin(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - social::delete_post(&state.pool, user.id, id, true).await?; - admin::audit(&state.pool, user.id, "delete_post", &format!("post:{id}")).await?; - Ok(Redirect::to("/admin").into_response()) +fn anchored_return(headers: &HeaderMap, post_id: i64, is_reply: bool, fallback: &str) -> String { + let anchor = if is_reply { + format!("reply-{post_id}") + } else { + format!("post-{post_id}") + }; + let base = referer_target(headers) + .as_deref() + .and_then(safe_return_target) + .unwrap_or_else(|| fallback.to_owned()); + let base = base.split('#').next().unwrap_or(fallback); + format!("{base}#{anchor}") } -async fn admin_toggle_post_nsfw( - State(state): State>, - headers: HeaderMap, - Path(id): Path, - Form(form): Form, -) -> AppResult { - let user = require_admin(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - let is_nsfw = match form.nsfw.as_str() { - "true" => true, - "false" => false, - _ => return Err(AppError::BadRequest("invalid NSFW setting".to_owned())), - }; - let changed = social::set_post_media_nsfw(&state.pool, id, is_nsfw) - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - if changed == 0 { - return Err(AppError::BadRequest("post has no media".to_owned())); +fn delete_return_fallback( + headers: &HeaderMap, + post_id: i64, + parent_post_id: Option, +) -> String { + let fallback = anchored_return(headers, post_id, parent_post_id.is_some(), "/home"); + if safe_delete_return_target(&fallback, post_id).is_some() { + return fallback; } - admin::audit( - &state.pool, - user.id, - if is_nsfw { - "mark_post_nsfw" - } else { - "unmark_post_nsfw" - }, - &format!("post:{id}"), + parent_post_id.map_or_else( + || format!("/home#post-{post_id}"), + |parent_id| format!("/posts/{parent_id}#post-{parent_id}"), ) - .await?; - Ok(redirect_to_post_anchor(&headers, id, false).into_response()) } -async fn admin_media( - State(state): State>, - headers: HeaderMap, -) -> AppResult> { - let user = require_admin(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let jobs = admin::media_jobs_report(&state.pool).await?; - let body = format!( - r#"

    Media jobs

    {}
    "#, - render_media_jobs_report(&jobs) - ); - Ok(Html( - page_layout(&state, Some(&user), Some(&csrf), "Media jobs", &body).await?, - )) +fn edit_return_fallback(headers: &HeaderMap, post_id: i64, parent_post_id: Option) -> String { + let fallback = anchored_return(headers, post_id, parent_post_id.is_some(), "/home"); + if safe_edit_return_target(&fallback, post_id).is_some() { + return fallback; + } + parent_post_id.map_or_else( + || format!("/posts/{post_id}"), + |parent_id| format!("/posts/{parent_id}#reply-{post_id}"), + ) } -async fn admin_deep_settings( - State(state): State>, - headers: HeaderMap, - Query(query): Query, -) -> AppResult> { - let user = require_admin(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let current = load_deep_settings(&state)?; - let values = admin::DeepSettingsValues::from_settings(¤t); - let notice = if query.saved.is_some() { - Some(("success", "Settings saved successfully")) - } else if query.discarded.is_some() { - Some(("info", "Changes discarded.")) - } else { +fn safe_edit_return_target(value: &str, post_id: i64) -> Option { + let target = safe_return_target(value)?; + let self_path = format!("/posts/{post_id}/edit"); + if path_without_query_or_fragment(&target) == self_path { None - }; - let body = render_deep_settings_form(&csrf, &values, notice); - deep_settings_html(&state, &user, &csrf, &body).await + } else { + Some(target) + } } -async fn admin_deep_settings_update( - State(state): State>, - headers: HeaderMap, - Form(form): Form, -) -> AppResult> { - let user = require_admin(&state, &headers).await?; - validate_csrf(&state.pool, &headers, &form.csrf).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let current = load_deep_settings(&state)?; - if form.intent.as_deref() == Some("discard") { - let values = admin::DeepSettingsValues::from_settings(¤t); - let body = render_deep_settings_form(&csrf, &values, Some(("info", "Changes discarded."))); - return deep_settings_html(&state, &user, &csrf, &body).await; +fn safe_delete_return_target(value: &str, post_id: i64) -> Option { + let target = safe_return_target(value)?; + let self_path = format!("/posts/{post_id}/delete"); + let thread_path = format!("/posts/{post_id}"); + let path = path_without_query_or_fragment(&target); + if path == self_path || path == thread_path { + None + } else { + Some(target) } +} - let values = match admin::parse_deep_settings_form(&form, ¤t) { - Ok(values) => values, - Err(err) => { - let fallback = admin::DeepSettingsValues::from_settings(¤t); - let body = - render_deep_settings_form(&csrf, &fallback, Some(("error", &err.to_string()))); - return deep_settings_html(&state, &user, &csrf, &body).await; - } - }; - let changes = admin::diff_deep_settings(¤t, &values); - if changes.is_empty() { - let body = - render_deep_settings_form(&csrf, &values, Some(("info", "No settings changed."))); - return deep_settings_html(&state, &user, &csrf, &body).await; +fn parse_notification_ids(value: &str) -> AppResult> { + if value.len() > 1024 { + return Err(AppError::BadRequest( + "notification group is too large".to_owned(), + )); } - - if form.intent.as_deref() == Some("confirm") { - let updated = values.apply_to(¤t); - if let Err(err) = admin::write_deep_settings(&state.paths.settings_path, &updated) { - tracing::error!(error = %err, "failed to save deep server settings"); - let body = render_deep_settings_confirmation( - &csrf, - &values, - &changes, - Some(( - "error", - "Settings could not be saved. Check the server logs for details.", - )), - ); - return deep_settings_html(&state, &user, &csrf, &body).await; + let mut ids = Vec::new(); + for part in value.split(',') { + let trimmed = part.trim(); + if trimmed.is_empty() { + continue; + } + let id = trimmed + .parse::() + .map_err(|_err| AppError::BadRequest("notification group is invalid".to_owned()))?; + if id <= 0 { + return Err(AppError::BadRequest( + "notification group is invalid".to_owned(), + )); + } + if !ids.contains(&id) { + ids.push(id); + } + if ids.len() > 80 { + return Err(AppError::BadRequest( + "notification group is too large".to_owned(), + )); } - admin::audit( - &state.pool, - user.id, - "update_deep_settings", - "settings.toml", - ) - .await?; - let saved = load_deep_settings(&state).unwrap_or(updated); - let values = admin::DeepSettingsValues::from_settings(&saved); - let body = render_deep_settings_form( - &csrf, - &values, - Some(("success", "Settings saved successfully")), - ); - return deep_settings_html(&state, &user, &csrf, &body).await; } - - let body = render_deep_settings_confirmation(&csrf, &values, &changes, None); - deep_settings_html(&state, &user, &csrf, &body).await + if ids.is_empty() { + return Err(AppError::BadRequest( + "notification group is invalid".to_owned(), + )); + } + Ok(ids) } -async fn deep_settings_html( - state: &AppState, - user: &CurrentUser, - csrf: &str, - body: &str, -) -> AppResult> { - Ok(Html( - page_layout(state, Some(user), Some(csrf), "Deep server settings", body).await?, - )) +fn parse_notification_group_target(value: Option<&str>) -> AppResult> { + let Some(value) = value else { + return Ok(None); + }; + let trimmed = value.trim(); + if trimmed.is_empty() { + return Ok(None); + } + let target = trimmed + .parse::() + .map_err(|_err| AppError::BadRequest("notification group target is invalid".to_owned()))?; + if target <= 0 { + return Err(AppError::BadRequest( + "notification group target is invalid".to_owned(), + )); + } + Ok(Some(target)) } -fn load_deep_settings(state: &AppState) -> AppResult { - let settings = Settings::load(&state.paths.settings_path)?; - settings.validate()?; - Ok(settings) +fn path_without_query_or_fragment(target: &str) -> &str { + target.split(['?', '#']).next().unwrap_or_default() } -fn render_deep_settings_form( - csrf: &str, - values: &admin::DeepSettingsValues, - notice: Option<(&str, &str)>, -) -> String { - let notice_html = - notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); - let fields = render_deep_settings_groups(values); - format!( - r#"{notice_html}

    Deep server settings

    Durable settings from settings.toml. Saved changes require a RustPost restart before this running server uses them.

    {fields}
    "#, - html_escape::encode_double_quoted_attribute(csrf), - ) +fn safe_return_target(value: &str) -> Option { + let target = if value.starts_with('/') && !value.starts_with("//") { + value.to_owned() + } else { + value.parse::().ok().and_then(|uri| { + uri.path_and_query() + .map(|path| path.as_str().to_owned()) + .filter(|path| path.starts_with('/')) + })? + }; + let path = target.split('#').next().unwrap_or_default(); + if matches!( + path, + "/home" | "/bookmarks" | "/notifications" | "/search" | "/" + ) || path.starts_with("/posts/") + || path.starts_with("/users/") + || path.starts_with("/tags/") + { + Some(target) + } else { + None + } } -fn render_deep_settings_groups(values: &admin::DeepSettingsValues) -> String { - let mut body = String::new(); - let mut active_section = ""; - for field in admin::DeepSettingsField::ALL { - if field.section() != active_section { - if !active_section.is_empty() { - body.push_str(""); - } - active_section = field.section(); - let _ = write!( - body, - r#"
    {}"#, - html_escape::encode_text(active_section) - ); - } - body.push_str(&render_deep_settings_field(field, values)); - } - if !active_section.is_empty() { - body.push_str("
    "); +fn referer_target(headers: &HeaderMap) -> Option { + let value = headers + .get(header::REFERER) + .and_then(|value| value.to_str().ok())?; + if value.starts_with('/') && !value.starts_with("//") { + Some(value.to_owned()) + } else { + let uri = value.parse::().ok()?; + uri.path_and_query() + .map(|path| path.as_str().to_owned()) + .filter(|path| path.starts_with('/')) } - body } -fn render_deep_settings_field( - field: admin::DeepSettingsField, - values: &admin::DeepSettingsValues, -) -> String { - let name = field.form_name(); - let id = format!("deep-{name}"); - let value = values.form_value(field); - let control = match field.input_kind() { - admin::DeepSettingsInputKind::Text => format!( - r#""#, - html_escape::encode_double_quoted_attribute(&value), - ), - admin::DeepSettingsInputKind::Number => format!( - r#""#, - html_escape::encode_double_quoted_attribute(&value), - ), - admin::DeepSettingsInputKind::Boolean => { - let true_selected = if value == "true" { " selected" } else { "" }; - let false_selected = if value == "false" { " selected" } else { "" }; - format!( - r#""# - ) - } +fn reject_cross_site_form_post(headers: &HeaderMap) -> AppResult<()> { + if headers + .get("sec-fetch-site") + .and_then(|value| value.to_str().ok()) + .is_some_and(|value| value.eq_ignore_ascii_case("cross-site")) + { + return Err(AppError::Forbidden); + } + let Some(origin) = headers + .get(header::ORIGIN) + .and_then(|value| value.to_str().ok()) + else { + return Ok(()); }; - let helper = field.helper().map_or_else(String::new, |helper| { - format!( - r#"

    {}

    "#, - html_escape::encode_text(helper) - ) + let Some(host) = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + else { + return Err(AppError::Forbidden); + }; + let origin_host = origin.parse::().ok().and_then(|uri| { + uri.authority() + .map(|authority| authority.as_str().to_owned()) }); - format!( - r#"
    {control}{helper}
    "#, - html_escape::encode_text(field.label()), - ) + if origin_host.is_none_or(|origin_host| !origin_host.eq_ignore_ascii_case(host)) { + return Err(AppError::Forbidden); + } + Ok(()) } -fn render_deep_settings_confirmation( - csrf: &str, - values: &admin::DeepSettingsValues, - changes: &[admin::DeepSettingsChange], - notice: Option<(&str, &str)>, -) -> String { - let notice_html = - notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); - let rows = changes - .iter() - .map(|change| { - format!( - r#"
  • {}
    {} -> {}
  • "#, - html_escape::encode_text(change.label), - html_escape::encode_text(&change.old_value), - html_escape::encode_text(&change.new_value), - ) - }) - .collect::>() - .join(""); - let hidden = render_deep_settings_hidden_fields(values); - format!( - r#"{notice_html}

    These settings are about to be changed

    Review the changed values before writing settings.toml. Saved changes require a RustPost restart before this running server uses them.

      {rows}
    {hidden}
    {hidden}
    "#, - html_escape::encode_double_quoted_attribute(csrf), - html_escape::encode_double_quoted_attribute(csrf), - ) +async fn bookmarks( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_user(&state, &headers).await?; + let posts = social::timeline(&state.pool, Some(user.id), "bookmarks", None).await?; + let csrf = form_csrf(&state, &headers).await; + let body = format!( + "{}{}", + render::page_header("Bookmarks", "Posts you saved for later."), + render::posts_with_controls_empty_state( + &posts, + Some(&user), + csrf.as_deref(), + blur_nsfw_media(&state, Some(&user)), + state.settings.posts.post_edit_window_seconds, + render::EmptyState::new("No bookmarks yet.", "Saved posts will appear here."), + ) + ); + Ok(Html( + page_layout(&state, Some(&user), csrf.as_deref(), "Bookmarks", &body).await?, + )) } -fn render_deep_settings_hidden_fields(values: &admin::DeepSettingsValues) -> String { - admin::DeepSettingsField::ALL - .iter() - .copied() - .fold(String::new(), |mut fields, field| { - let _ = write!( - fields, - r#""#, - field.form_name(), - html_escape::encode_double_quoted_attribute(&values.form_value(field)) - ); - fields - }) +async fn following( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let user = require_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let (accounts, has_more) = + social::following_accounts(&state.pool, user.id, query.after).await?; + let body = format!( + "{}{}{}", + render::page_header("Following", "Accounts you follow."), + render::accounts(&accounts, &csrf), + account_list_next_link("/following", &accounts, has_more) + ); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Following", &body).await?, + )) } -fn render_media_jobs_report(report: &admin::MediaJobsReport) -> String { - if report.total == 0 { - return r#"

    No media jobs yet.

    "#.to_owned(); +async fn notifications( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_user(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let items = social::notification_groups(&state.pool, user.id).await?; + let unread_count = social::unread_notification_count(&state.pool, user.id).await?; + let body = render::notifications_page(&items, unread_count, &csrf); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Notifications", &body).await?, + )) +} + +async fn open_notification_group( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + if let Some(kind) = form.group_kind.as_deref() { + let target_post_id = parse_notification_group_target(form.group_target_post_id.as_deref())?; + social::mark_notification_group_read(&state.pool, user.id, kind, target_post_id).await?; + } else { + let notification_ids = parse_notification_ids(&form.notification_ids)?; + social::mark_notification_ids_read(&state.pool, user.id, ¬ification_ids).await?; } + let target = safe_return_target(&form.return_to).unwrap_or_else(|| "/notifications".to_owned()); + Ok(Redirect::to(&target).into_response()) +} - let mut out = String::new(); - let pending_age = match ( - report.newest_pending_age_seconds, - report.oldest_pending_age_seconds, - ) { - (Some(newest), Some(oldest)) => { - format!( - "{} newest / {} oldest", - format_age(newest), - format_age(oldest) - ) - } - _ => "none".to_owned(), - }; - let _ = write!( - out, - r#"
    TotalPendingRunningSucceededFailedPending age
    {}{}{}{}{}{}
    "#, - report.total, - report.pending, - report.running, - report.succeeded, - report.failed, - html_escape::encode_text(&pending_age), - ); +async fn mark_notifications_read( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + let user = require_user(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + social::mark_notifications_read(&state.pool, user.id).await?; + Ok(Redirect::to("/notifications").into_response()) +} - if report.recent_failures.is_empty() { - if report.failed == 0 { - out.push_str(r#"

    No recent media job failures.

    "#); - } - return out; - } +async fn mention_suggestions( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult>> { + let user = current(&state, &headers).await?; + let suggestions = social::mention_suggestions( + &state.pool, + user.as_ref().map(|user| user.id), + query.q.as_deref().unwrap_or_default(), + ) + .await?; + Ok(Json( + suggestions + .into_iter() + .map(|suggestion| MentionSuggestionResponse { + username: suggestion.username, + display_name: suggestion.display_name, + }) + .collect(), + )) +} - out.push_str( - r#"

    Recent failures

    "#, +async fn search( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let user = current(&state, &headers).await?; + let q = normalize_search_query(query.q.as_deref().unwrap_or_default()); + let (users, posts) = if q.is_empty() { + (Vec::new(), Vec::new()) + } else { + social::search(&state.pool, user.as_ref().map(|u| u.id), &q).await? + }; + let csrf = form_csrf(&state, &headers).await; + let body = render::search_page( + &state.settings.site.name, + &q, + &users, + &posts, + user.as_ref(), + csrf.as_deref(), + render::SearchRenderOptions { + blur_nsfw_media: blur_nsfw_media(&state, user.as_ref()), + post_edit_window_seconds: state.settings.posts.post_edit_window_seconds, + }, ); - for failure in &report.recent_failures { - let media = failure - .media_path - .as_deref() - .map(|path| compact_text(path, 48)) - .or_else(|| failure.media_id.map(|id| format!("#{id}"))) - .unwrap_or_else(|| "unknown".to_owned()); - let kind = failure.job_kind.as_deref().unwrap_or("media"); - let age = failure - .age_seconds - .map_or_else(|| "unknown".to_owned(), format_age); - let error = compact_text(&failure.error_summary, 80); - let _ = write!( - out, - r#""#, - failure.id, - html_escape::encode_text(&media), - html_escape::encode_text(kind), - html_escape::encode_text(&age), - html_escape::encode_text(&error), - ); - } - out.push_str("
    JobMediaKindAgeError
    #{}{}{}{}{}
    "); - out + Ok(Html( + page_layout(&state, user.as_ref(), csrf.as_deref(), "Search", &body).await?, + )) } -fn compact_text(input: &str, max_chars: usize) -> String { - let compact = input.split_whitespace().collect::>().join(" "); - if compact.chars().count() <= max_chars { - return compact; - } +fn normalize_search_query(query: &str) -> String { + query.split_whitespace().collect::>().join(" ") +} - let take = max_chars.saturating_sub(3); - let mut shortened = compact.chars().take(take).collect::(); - shortened.push_str("..."); - shortened +async fn tag( + State(state): State>, + headers: HeaderMap, + Path(tag): Path, +) -> AppResult> { + search( + State(state), + headers, + Query(SearchQuery { + q: Some(format!("#{tag}")), + }), + ) + .await } -fn format_age(seconds: i64) -> String { - let seconds = seconds.max(0); - if seconds < 60 { - return format!("{seconds}s"); - } - let minutes = seconds / 60; - if minutes < 60 { - return format!("{minutes}m"); - } - let hours = minutes / 60; - if hours < 48 { - return format!("{hours}h"); - } - format!("{}d", hours / 24) +#[derive(Deserialize)] +struct AdminDashboardQuery { + saved: Option, } -async fn admin_backups( +async fn admin_dashboard( State(state): State>, headers: HeaderMap, + Query(query): Query, ) -> AppResult> { let user = require_admin(&state, &headers).await?; let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - backups_page(&state, &user, &csrf, None).await + let saved_notice = match query.saved.as_deref() { + Some("announcement") => Some(render::notice("success", "Announcement updated.")), + Some("maintenance") => Some(render::notice("success", "Maintenance setting updated.")), + _ => None, + } + .unwrap_or_default(); + let favicon_asset = favicon::current(&state.paths); + let remove_form = if favicon_asset.is_custom() { + small_form( + "/admin/favicon/remove", + &csrf, + "Remove favicon", + "Reset to the built-in favicon", + ) + } else { + String::new() + }; + let favicon_panel = format!( + r#"

    Favicon

    {}

    Current favicon

    Accepted: .ico, .png, .svg. Maximum size: 256 KiB.

    {}
    "#, + html_escape::encode_text(favicon_asset.state_label()), + html_escape::encode_double_quoted_attribute(&csrf), + remove_form + ); + let instance = instance::load(&state.pool).await?; + let instance_panels = admin_instance_panels(&instance, &csrf); + let body = format!( + "{}{}{}{}{}", + render::page_header( + "Admin", + "Manage site health, users, media jobs, settings, and backups." + ), + saved_notice, + r#"
    Site healthUsersMedia jobsDeep server settingsBackups
    "#, + instance_panels, + favicon_panel + ); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Admin", &body).await?, + )) +} + +/// Announcement and maintenance controls, persisted in `instance_settings`. +fn admin_instance_panels(instance: &instance::InstanceSettings, csrf: &str) -> String { + let announcement_enabled = if instance.announcement_enabled { + " checked" + } else { + "" + }; + let maintenance_enabled = if instance.maintenance_mode { + " checked" + } else { + "" + }; + let announcement_status = if instance.announcement_text().is_some() { + "Currently visible in the top bar." + } else { + "Not currently shown." + }; + let maintenance_status = if instance.maintenance_mode { + "Maintenance mode is ON. Posting and registration are disabled." + } else { + "Maintenance mode is off. The site is fully available." + }; + format!( + r#"

    Announcement

    {announcement_status}

    Maintenance mode

    {maintenance_status}

    While maintenance mode is on, registration and post creation (posts, replies, quotes, and reposts) are blocked. Administrators can still post, and the site stays readable.

    "#, + csrf = html_escape::encode_double_quoted_attribute(csrf), + announcement = html_escape::encode_text(&instance.announcement), + maintenance_message = html_escape::encode_text(&instance.maintenance_message), + announcement_status = html_escape::encode_text(announcement_status), + maintenance_status = html_escape::encode_text(maintenance_status), + ) } #[derive(Deserialize)] -struct BackupForm { +struct AnnouncementForm { csrf: String, - include_tor_keys: Option, + announcement: Option, + enabled: Option, + intent: Option, } -async fn admin_create_backup( +async fn admin_update_announcement( State(state): State>, headers: HeaderMap, - Form(form): Form, -) -> AppResult> { + Form(form): Form, +) -> AppResult { let user = require_admin(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let settings = load_deep_settings(&state)?; - if !settings.backup.enabled { - return backups_page( - &state, - &user, - &csrf, - Some(("error", "Backups are disabled in settings.toml.")), + if form.intent.as_deref() == Some("clear") { + instance::save_announcement(&state.pool, "", false).await?; + admin::audit( + &state.pool, + user.id, + "update_announcement", + "instance_settings", ) - .await; - } - let include_tor = form.include_tor_keys.is_some(); - let paths = state.paths.clone(); - let created = tokio::task::spawn_blocking(move || backup::create_backup(&paths, include_tor)) - .await - .map_err(|err| AppError::BadRequest(format!("backup task failed: {err}")))?; - let archive = match created { - Ok(archive) => archive, - Err(err) => { - tracing::warn!(error = %err, "manual backup failed"); - let message = public_backup_error("Backup failed", &err); - return backups_page(&state, &user, &csrf, Some(("error", &message))).await; + .await?; + return Ok(Redirect::to("/admin?saved=announcement").into_response()); + } + let text = form.announcement.unwrap_or_default(); + let enabled = form.enabled.as_deref() == Some("true"); + match instance::save_announcement(&state.pool, &text, enabled).await { + Ok(()) => { + admin::audit( + &state.pool, + user.id, + "update_announcement", + "instance_settings", + ) + .await?; + Ok(Redirect::to("/admin?saved=announcement").into_response()) } - }; - admin::audit( - &state.pool, - user.id, - "create_backup", - archive - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("backup"), - ) - .await?; - let message = format!( - "Backup created: {}", - archive - .file_name() - .and_then(|name| name.to_str()) - .unwrap_or("archive") - ); - backups_page(&state, &user, &csrf, Some(("success", &message))).await + Err(err) => admin_panel_error(&state, &user, &headers, &err.to_string()).await, + } } -async fn admin_download_backup( +#[derive(Deserialize)] +struct MaintenanceForm { + csrf: String, + message: Option, + enabled: Option, +} + +async fn admin_update_maintenance( State(state): State>, headers: HeaderMap, - Path(filename): Path, + Form(form): Form, ) -> AppResult { - require_admin(&state, &headers).await?; - let path = backup::backup_path_for_download(&state.paths, &filename) - .map_err(|_err| AppError::NotFound)?; - let metadata = tokio::fs::metadata(&path).await?; - let file = tokio::fs::File::open(&path).await?; - let stream = futures_util::stream::unfold(file, |mut file| async move { - let mut buffer = vec![0u8; 64 * 1024]; - match file.read(&mut buffer).await { - Ok(0) => None, - Ok(read) => { - buffer.truncate(read); - Some((Ok::(Bytes::from(buffer)), file)) - } - Err(error) => Some((Err(error), file)), + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let enabled = form.enabled.as_deref() == Some("true"); + let message = form.message.unwrap_or_default(); + match instance::save_maintenance(&state.pool, enabled, &message).await { + Ok(()) => { + admin::audit( + &state.pool, + user.id, + if enabled { + "enable_maintenance" + } else { + "disable_maintenance" + }, + "instance_settings", + ) + .await?; + Ok(Redirect::to("/admin?saved=maintenance").into_response()) } - }); - let disposition = format!("attachment; filename=\"{}\"", filename.replace('"', "")); - let mut response = Body::from_stream(stream).into_response(); - response.headers_mut().insert( - header::CONTENT_TYPE, - HeaderValue::from_static("application/x-tar"), - ); - response.headers_mut().insert( - header::CONTENT_LENGTH, - HeaderValue::from_str(&metadata.len().to_string()) - .map_err(|err| AppError::BadRequest(err.to_string()))?, - ); - response.headers_mut().insert( - header::CONTENT_DISPOSITION, - HeaderValue::from_str(&disposition).map_err(|err| AppError::BadRequest(err.to_string()))?, + Err(err) => admin_panel_error(&state, &user, &headers, &err.to_string()).await, + } +} + +async fn admin_panel_error( + state: &AppState, + user: &CurrentUser, + headers: &HeaderMap, + message: &str, +) -> AppResult { + let csrf = form_csrf(state, headers).await.unwrap_or_default(); + let instance = instance::load(&state.pool).await?; + let body = format!( + "{}{}{}", + render::notice("error", message), + admin_instance_panels(&instance, &csrf), + r#"

    Back to admin

    "# ); - Ok(response) + Ok(( + StatusCode::BAD_REQUEST, + Html(page_layout(state, Some(user), Some(&csrf), "Admin", &body).await?), + ) + .into_response()) } -async fn admin_backup_settings_update( +async fn admin_require_password_reset( State(state): State>, headers: HeaderMap, - Form(form): Form, -) -> AppResult> { + Path(id): Path, + Form(form): Form, +) -> AppResult { let user = require_admin(&state, &headers).await?; validate_csrf(&state.pool, &headers, &form.csrf).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let current = load_deep_settings(&state)?; - let values = match admin::parse_backup_settings_form(&form, ¤t) { - Ok(values) => values, - Err(err) => { - let message = err.to_string(); - return backups_page(&state, &user, &csrf, Some(("error", &message))).await; - } - }; - let updated = values.apply_to(¤t); - if let Err(err) = admin::write_backup_settings(&state.paths.settings_path, &updated) { - tracing::error!(error = %err, "failed to save backup settings"); - return backups_page( - &state, - &user, - &csrf, - Some(( - "error", - "Backup settings could not be saved. Check the server logs.", - )), - ) - .await; - } - admin::audit( - &state.pool, - user.id, - "update_backup_settings", - "settings.toml", - ) - .await?; - backups_page( - &state, - &user, - &csrf, - Some(( - "success", - "Backup settings saved. The scheduler reads settings.toml on its next check.", - )), - ) - .await + identity::require_password_reset(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/admin/users").into_response()) } -async fn admin_restore_backup( +async fn admin_revoke_sessions( State(state): State>, headers: HeaderMap, - multipart: Multipart, -) -> AppResult> { + Path(id): Path, + Form(form): Form, +) -> AppResult { let user = require_admin(&state, &headers).await?; - let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); - let upload = match parse_restore_upload(&state, &headers, multipart).await { - Ok(upload) => upload, - Err(err) => { - return backups_page(&state, &user, &csrf, Some(("error", &err.to_string()))).await; - } - }; - if upload.confirmation != "RESTORE" { - let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; - return backups_page( - &state, - &user, - &csrf, - Some(( - "error", - "Type RESTORE to confirm restoring from an uploaded backup.", - )), - ) - .await; - } - admin::audit( - &state.pool, - user.id, - "restore_backup_upload", - &upload.filename, - ) - .await?; - let paths = state.paths.clone(); - let archive_path = upload.archive_path.clone(); - let include_tor_keys = upload.include_tor_keys; - let restored = tokio::task::spawn_blocking(move || { - backup::restore_backup(&paths, &archive_path, include_tor_keys) - }) - .await - .map_err(|err| AppError::BadRequest(format!("restore task failed: {err}")))?; - let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; - match restored { - Ok(report) => { - let safety = report - .pre_restore_backup - .as_ref() - .and_then(|path| path.file_name()) - .and_then(|name| name.to_str()) - .unwrap_or("pre-restore backup"); - let message = format!( - "Restore completed. Restart RustPost so the running process reopens the restored database. Safety backup: {safety}." - ); - backups_page(&state, &user, &csrf, Some(("success", &message))).await - } - Err(err) => { - tracing::warn!(error = %err, "restore upload failed"); - let message = public_backup_error("Restore failed", &err); - backups_page(&state, &user, &csrf, Some(("error", &message))).await - } + validate_csrf(&state.pool, &headers, &form.csrf).await?; + identity::revoke_user_sessions(&state.pool, user.id, id) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + Ok(Redirect::to("/admin/users").into_response()) +} + +async fn admin_favicon_upload( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> AppResult { + require_admin(&state, &headers).await?; + let parsed = parse_favicon_upload(&state, &headers, multipart).await?; + if !parsed.uploaded { + return Err(AppError::BadRequest( + "choose a .ico, .png, or .svg favicon to upload".to_owned(), + )); } + Ok(Redirect::to("/admin").into_response()) } -struct ParsedRestoreUpload { - archive_path: PathBuf, - filename: String, - include_tor_keys: bool, - confirmation: String, +async fn admin_favicon_remove( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult { + require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + favicon::reset(&state.paths).await?; + Ok(Redirect::to("/admin").into_response()) } -async fn parse_restore_upload( +async fn parse_favicon_upload( state: &AppState, headers: &HeaderMap, mut multipart: Multipart, -) -> AppResult { +) -> AppResult { + let mut parsed = ParsedFaviconUpload { uploaded: false }; let mut csrf_validated = false; - let mut include_tor_keys = false; - let mut confirmation = String::new(); - let mut archive_path = None; - let mut filename = "uploaded-backup.tar".to_owned(); while let Some(field) = multipart .next_field() .await @@ -4291,38 +5031,20 @@ async fn parse_restore_upload( validate_csrf(&state.pool, headers, &token).await?; csrf_validated = true; } - "include_tor_keys" => { - include_tor_keys = field.text().await.is_ok_and(|value| value == "true"); - } - "restore_confirm" => { - confirmation = field - .text() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))?; - } - "backup" if field.file_name().is_some() => { + "favicon" if field.file_name().is_some() => { + if field.file_name().is_none_or(|name| name.trim().is_empty()) { + continue; + } if !csrf_validated { return Err(AppError::Forbidden); } - field - .file_name() - .filter(|name| !name.trim().is_empty()) - .unwrap_or("uploaded-backup.tar") - .clone_into(&mut filename); - if !std::path::Path::new(&filename) - .extension() - .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) - { - return Err(AppError::BadRequest( - "Choose a .tar backup archive.".to_owned(), - )); - } - let path = state - .paths - .tmp_dir - .join(format!("restore-upload-{}.tar", Uuid::new_v4().simple())); - write_multipart_field_to_file(field, &path).await?; - archive_path = Some(path); + favicon::save_upload(&state.paths, field) + .await + .map_err(|err| { + tracing::warn!(error = %err, "favicon upload rejected"); + AppError::BadRequest(err.to_string()) + })?; + parsed.uploaded = true; } _ => {} } @@ -4330,738 +5052,2776 @@ async fn parse_restore_upload( if !csrf_validated { return Err(AppError::Forbidden); } - Ok(ParsedRestoreUpload { - archive_path: archive_path.ok_or_else(|| { - AppError::BadRequest("Choose a backup archive to restore.".to_owned()) - })?, - filename, - include_tor_keys, - confirmation, - }) + Ok(parsed) } -async fn write_multipart_field_to_file( - mut field: axum::extract::multipart::Field<'_>, - path: &std::path::Path, -) -> AppResult<()> { - if let Some(parent) = path.parent() { - tokio::fs::create_dir_all(parent).await?; - } - let mut output = tokio::fs::File::create(path).await?; - while let Some(chunk) = field - .chunk() - .await - .map_err(|err| AppError::BadRequest(err.to_string()))? - { - output.write_all(&chunk).await?; - } - output.flush().await?; - Ok(()) -} - -async fn backups_page( - state: &AppState, - user: &CurrentUser, - csrf: &str, - notice: Option<(&str, &str)>, +async fn admin_health( + State(state): State>, + headers: HeaderMap, ) -> AppResult> { - let settings = load_deep_settings(state)?; - let archives = backup::list_backups(&state.paths)?; - let notice_html = - notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let user = require_admin(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let media_jobs = admin::media_jobs_report(&state.pool).await?; + let jobs = render_media_jobs_report(&media_jobs); + let schema = crate::db::schema_report(&state.pool).await?; + let schema_version = if schema.is_compatible() { + schema + .version() + .map_or_else(|| "unknown".to_owned(), |version| version.to_string()) + } else { + "incompatible".to_owned() + }; + let schema_summary = if schema.is_compatible() { + schema.summary() + } else { + format!("INCOMPATIBLE: {}", schema.summary()) + }; + let onion = state + .tor + .onion_address() + .unwrap_or_else(|| "unavailable".to_owned()); + let tor_error = state.tor.error().unwrap_or_else(|| "none".to_owned()); + let bootstrap = state + .tor + .bootstrap_status() + .unwrap_or_else(|| "unavailable".to_owned()); let body = format!( - "{}{}{}{}{}{}", - notice_html, - render::page_header( - "Backups", - "Create, download, schedule, and restore full-site runtime backups." - ), - render_manual_backup_panel(csrf, &settings), - render_automatic_backup_panel(csrf, &settings), - render_backup_history(&archives, backup::operation_in_progress(&state.paths)), - render_restore_panel(csrf), + r#"

    Site health

    DB path
    {}
    DB schema version
    {}
    DB diagnostics
    {}
    Upload path
    {}
    Media path
    {}
    Logs path
    {}
    Backup path
    {}
    ffmpeg
    {}
    WebP support
    {}
    VP9 support
    {}
    Tor
    {}
    Tor enabled
    {}
    Tor service active
    {}
    Tor bootstrap
    {}
    Tor error
    {}
    Onion address
    {}
    Anonymous mode
    {}
    Registration
    {}

    Recent media jobs

    {}
    "#, + html_escape::encode_text(&state.paths.database_path.display().to_string()), + html_escape::encode_text(&schema_version), + html_escape::encode_text(&schema_summary), + html_escape::encode_text(&state.paths.uploads_originals.display().to_string()), + html_escape::encode_text(&state.paths.uploads_images.display().to_string()), + html_escape::encode_text(&state.paths.logs_dir.display().to_string()), + html_escape::encode_text(&state.paths.backups_dir.display().to_string()), + html_escape::encode_text(&state.ffmpeg.summary()), + state.ffmpeg.supports_webp, + state.ffmpeg.supports_vp9, + html_escape::encode_text(&state.tor.summary()), + state.tor.enabled(), + state.tor.running(), + html_escape::encode_text(&bootstrap), + html_escape::encode_text(&tor_error), + html_escape::encode_text(&onion), + state.settings.accounts.anonymous_mode_enabled, + state.settings.accounts.registration_enabled, + jobs ); Ok(Html( - page_layout(state, Some(user), Some(csrf), "Backups", &body).await?, + page_layout(&state, Some(&user), Some(&csrf), "Site health", &body).await?, )) } -fn render_manual_backup_panel(csrf: &str, settings: &Settings) -> String { - let disabled = if settings.backup.enabled { - "" +async fn admin_users( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let user_query = query.user_q.unwrap_or_default(); + let post_query = query.post_q.unwrap_or_default(); + let search = admin::AdminUserSearch::new(&user_query, &post_query); + let malformed_quotes = search.post_search.malformed_quotes; + let has_filter = search.has_filter(); + let rows = admin::users(&state.pool, search).await?; + let quote_notice = if malformed_quotes { + r#"

    Post search had unmatched quotes, so it was treated as plain keyword search.

    "# } else { - " disabled" + "" }; - format!( - r#"

    Manual backup

    Archives are written under the private backup directory and are available only to admins.

    Leave Tor keys unchecked unless the archive storage is encrypted and access-controlled.

    "#, - html_escape::encode_double_quoted_attribute(csrf), - ) + let list = admin_user_rows(&rows, &csrf, has_filter); + let body = format!( + r#"

    Users

    {}{}
    "#, + html_escape::encode_double_quoted_attribute(&user_query), + html_escape::encode_double_quoted_attribute(&post_query), + quote_notice, + list + ); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Admin users", &body).await?, + )) } -fn render_automatic_backup_panel(csrf: &str, settings: &Settings) -> String { - let values = admin::BackupSettingsValues::from_settings(settings); - format!( - r#"

    Automatic backups

    {}
    {}

    Set 0 to disable age cleanup. Manual and pre-restore backups are not pruned.

    {}
    "#, - html_escape::encode_double_quoted_attribute(csrf), - bool_select("backup-enabled", "enabled", values.enabled), - bool_select( - "backup-auto-enabled", - "automatic_enabled", - values.automatic_enabled - ), - values.automatic_interval_minutes, - values.retention_keep_last, - values.retention_max_age_days, - bool_select( - "backup-auto-tor", - "automatic_include_tor_keys", - values.automatic_include_tor_keys - ), - ) -} +fn admin_user_rows(rows: &[admin::AdminUserInvestigation], csrf: &str, searched: bool) -> String { + if rows.is_empty() { + let message = if searched { + "No users matched those filters." + } else { + "No users found." + }; + return render::compact_empty_state_with_class( + "admin-users-empty", + message, + "Try a different username, handle, display name, or post keyword.", + ); + } -fn bool_select(id: &str, name: &str, value: bool) -> String { - let true_selected = if value { " selected" } else { "" }; - let false_selected = if value { "" } else { " selected" }; - format!( - r#""#, - html_escape::encode_double_quoted_attribute(id), - html_escape::encode_double_quoted_attribute(name), - ) + rows.iter() + .map(|row| admin_user_row(row, csrf, searched)) + .collect::>() + .join("") } -fn render_backup_history( - archives: &[backup::BackupArchiveInfo], - operation_running: bool, -) -> String { - let status = if archives.is_empty() { - r#"

    No backups have been created yet.

    "#.to_owned() +fn admin_user_row(row: &admin::AdminUserInvestigation, csrf: &str, searched: bool) -> String { + let display_name = if row.display_name.trim().is_empty() { + row.username.as_str() } else { - let rows = archives - .iter() - .take(20) - .map(|archive| { - let created = archive.created_at.as_deref().unwrap_or("unknown"); - let tor_keys = archive - .tor_keys_included - .map_or("unknown", |included| if included { "included" } else { "excluded" }); - let kind = if archive.automatic { "automatic" } else { "manual" }; - let manifest = if archive.manifest_valid { "valid" } else { "unreadable" }; - format!( - r#"{}{}{}{}{}{}Download"#, - html_escape::encode_text(&archive.filename), - html_escape::encode_text(kind), - html_escape::encode_text(created), - format_bytes(archive.size), - html_escape::encode_text(tor_keys), - html_escape::encode_text(manifest), - html_escape::encode_double_quoted_attribute(&archive.filename), - ) - }) - .collect::>() - .join(""); - format!( - r#"{rows}
    ArchiveKindCreatedSizeTor keysManifestAction
    "# - ) + row.display_name.as_str() }; - let running = if operation_running { - render::notice( - "info", - "A backup or restore operation is currently running.", + let statuses = [ + if row.is_admin { "Admin" } else { "Member" }, + if row.is_suspended { + "Suspended" + } else { + "Active" + }, + if row.is_deleted { + "Deleted" + } else { + "Not deleted" + }, + ] + .iter() + .map(|status| { + format!( + r#"{}"#, + html_escape::encode_text(status) ) - } else { - String::new() - }; + }) + .collect::>() + .join(""); + let match_labels = admin_user_match_labels(row, searched); + let preview = row + .post_match_preview + .as_ref() + .map_or_else(String::new, |text| { + format!( + r#"

    Post match preview: {}

    "#, + html_escape::encode_text(&short_preview(text)) + ) + }); + let action = small_form( + &format!("/admin/users/{}/suspend", row.id), + csrf, + if row.is_suspended { + "Unsuspend" + } else { + "Suspend" + }, + if row.is_suspended { + "Unsuspend this account" + } else { + "Suspend this account" + }, + ); + let reset_password = small_form( + &format!("/admin/users/{}/require-password-reset", row.id), + csrf, + "Require password reset", + "Require this account to choose a new password before using the site again", + ); + let revoke_sessions = small_form( + &format!("/admin/users/{}/revoke-sessions", row.id), + csrf, + "Log out sessions", + "Revoke every active session for this account", + ); + let actions = format!("{action}{reset_password}{revoke_sessions}"); format!( - r#"

    Recent backups

    {running}{status}
    "# - ) -} + r#"
    {} @{} #{}
    {}
    {}
    Created
    {}
    Updated
    {}
    Last session
    {}
    Last post
    {}
    Total posts
    {}
    Uploaded media
    {}
    Reports on posts
    {}
    Moderation actions
    {}
    Matching posts
    {}
    {}
    "#, + html_escape::encode_double_quoted_attribute(&row.username), + html_escape::encode_text(display_name), + html_escape::encode_text(&row.username), + row.id, + statuses, + match_labels, + html_escape::encode_text(&row.created_at), + html_escape::encode_text(&row.updated_at), + html_escape::encode_text(row.last_session_at.as_deref().unwrap_or("No session")), + html_escape::encode_text(row.last_post_at.as_deref().unwrap_or("No posts")), + row.total_posts, + row.uploaded_media_count, + row.reports_on_posts_count, + row.moderation_action_count, + row.matching_post_count, + preview, + actions + ) +} -fn render_restore_panel(csrf: &str) -> String { - format!( - r#"

    Danger area

    Restore from upload

    Restore validates the manifest, hashes, paths, settings, and SQLite integrity in a staging directory before replacing live runtime files. A pre-restore safety backup is created first.

    "#, - html_escape::encode_double_quoted_attribute(csrf), +fn admin_user_match_labels(row: &admin::AdminUserInvestigation, searched: bool) -> String { + if !searched { + return String::new(); + } + + let mut labels = Vec::new(); + if row.matched_name { + labels.push("Matched name".to_owned()); + } + if row.matching_post_count > 0 { + labels.push(format!("Matched post content: {}", row.matching_post_count)); + } + if labels.is_empty() { + return String::new(); + } + let labels = labels + .iter() + .map(|label| { + format!( + r#"{}"#, + html_escape::encode_text(label) + ) + }) + .collect::>() + .join(""); + format!(r#"
    {labels}
    "#) +} + +fn short_preview(text: &str) -> String { + const LIMIT: usize = 140; + let trimmed = text.trim(); + let mut preview = trimmed.chars().take(LIMIT).collect::(); + if trimmed.chars().count() > LIMIT { + preview.push_str("..."); + } + preview +} + +async fn admin_suspend( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let current: i64 = state + .pool + .call(move |conn| { + Ok( + conn.query_row("SELECT is_suspended FROM users WHERE id = ?", [id], |row| { + row.get(0) + })?, + ) + }) + .await?; + admin::set_user_suspended(&state.pool, user.id, id, current == 0).await?; + Ok(Redirect::to("/admin/users").into_response()) +} + +async fn admin_delete_post( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + media::validate_post_media_deletion(&state.pool, &state.paths, id).await?; + social::delete_post(&state.pool, user.id, id, true).await?; + if let Err(error) = media::delete_post_media(&state.pool, &state.paths, id).await { + tracing::warn!(post_id = id, error = %error, "post deleted but media cleanup failed"); + } + admin::audit(&state.pool, user.id, "delete_post", &format!("post:{id}")).await?; + Ok(Redirect::to("/admin").into_response()) +} + +async fn admin_toggle_post_nsfw( + State(state): State>, + headers: HeaderMap, + Path(id): Path, + Form(form): Form, +) -> AppResult { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let is_nsfw = match form.nsfw.as_str() { + "true" => true, + "false" => false, + _ => return Err(AppError::BadRequest("invalid NSFW setting".to_owned())), + }; + let changed = social::set_post_media_nsfw(&state.pool, id, is_nsfw) + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + if changed == 0 { + return Err(AppError::BadRequest("post has no media".to_owned())); + } + admin::audit( + &state.pool, + user.id, + if is_nsfw { + "mark_post_nsfw" + } else { + "unmark_post_nsfw" + }, + &format!("post:{id}"), ) + .await?; + Ok(redirect_to_post_anchor(&headers, id, false).into_response()) +} + +async fn admin_media( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let jobs = admin::media_jobs_report(&state.pool).await?; + let body = format!( + r#"

    Media jobs

    {}
    "#, + render_media_jobs_report(&jobs) + ); + Ok(Html( + page_layout(&state, Some(&user), Some(&csrf), "Media jobs", &body).await?, + )) +} + +async fn admin_deep_settings( + State(state): State>, + headers: HeaderMap, + Query(query): Query, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let current = load_deep_settings(&state)?; + let values = admin::DeepSettingsValues::from_settings(¤t); + let notice = if query.saved.is_some() { + Some(("success", "Settings saved successfully")) + } else if query.discarded.is_some() { + Some(("info", "Changes discarded.")) + } else { + None + }; + let body = render_deep_settings_form(&csrf, &values, notice); + deep_settings_html(&state, &user, &csrf, &body).await +} + +async fn admin_deep_settings_update( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let current = load_deep_settings(&state)?; + if form.intent.as_deref() == Some("discard") { + let values = admin::DeepSettingsValues::from_settings(¤t); + let body = render_deep_settings_form(&csrf, &values, Some(("info", "Changes discarded."))); + return deep_settings_html(&state, &user, &csrf, &body).await; + } + + let values = match admin::parse_deep_settings_form(&form, ¤t) { + Ok(values) => values, + Err(err) => { + let fallback = admin::DeepSettingsValues::from_settings(¤t); + let body = + render_deep_settings_form(&csrf, &fallback, Some(("error", &err.to_string()))); + return deep_settings_html(&state, &user, &csrf, &body).await; + } + }; + let changes = admin::diff_deep_settings(¤t, &values); + if changes.is_empty() { + let body = + render_deep_settings_form(&csrf, &values, Some(("info", "No settings changed."))); + return deep_settings_html(&state, &user, &csrf, &body).await; + } + + if form.intent.as_deref() == Some("confirm") { + let updated = values.apply_to(¤t); + if let Err(err) = admin::write_deep_settings(&state.paths.settings_path, &updated) { + tracing::error!(error = %err, "failed to save deep server settings"); + let body = render_deep_settings_confirmation( + &csrf, + &values, + &changes, + Some(( + "error", + "Settings could not be saved. Check the server logs for details.", + )), + ); + return deep_settings_html(&state, &user, &csrf, &body).await; + } + state.nsfw_blur_default.store( + updated.media.nsfw_blur_enabled, + std::sync::atomic::Ordering::Relaxed, + ); + admin::audit( + &state.pool, + user.id, + "update_deep_settings", + "settings.toml", + ) + .await?; + let saved = load_deep_settings(&state).unwrap_or(updated); + let values = admin::DeepSettingsValues::from_settings(&saved); + let body = render_deep_settings_form( + &csrf, + &values, + Some(("success", "Settings saved successfully")), + ); + return deep_settings_html(&state, &user, &csrf, &body).await; + } + + let body = render_deep_settings_confirmation(&csrf, &values, &changes, None); + deep_settings_html(&state, &user, &csrf, &body).await +} + +async fn deep_settings_html( + state: &AppState, + user: &CurrentUser, + csrf: &str, + body: &str, +) -> AppResult> { + Ok(Html( + page_layout(state, Some(user), Some(csrf), "Deep server settings", body).await?, + )) +} + +fn load_deep_settings(state: &AppState) -> AppResult { + let settings = Settings::load(&state.paths.settings_path)?; + settings.validate()?; + Ok(settings) } -fn public_backup_error(prefix: &str, error: &anyhow::Error) -> String { - let detail = error.to_string(); - if detail.contains('/') || detail.contains('\\') { - format!("{prefix}. Check the server logs for details.") - } else { - format!("{prefix}: {detail}") +fn render_deep_settings_form( + csrf: &str, + values: &admin::DeepSettingsValues, + notice: Option<(&str, &str)>, +) -> String { + let notice_html = + notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let fields = render_deep_settings_groups(values); + format!( + r#"{notice_html}

    Deep server settings

    Durable settings from settings.toml. Saved changes require a RustPost restart before this running server uses them.

    {fields}
    "#, + html_escape::encode_double_quoted_attribute(csrf), + ) +} + +fn render_deep_settings_groups(values: &admin::DeepSettingsValues) -> String { + let mut body = String::new(); + let mut active_section = ""; + for field in admin::DeepSettingsField::ALL { + if field.section() != active_section { + if !active_section.is_empty() { + body.push_str(""); + } + active_section = field.section(); + let _ = write!( + body, + r#"
    {}"#, + html_escape::encode_text(active_section) + ); + } + body.push_str(&render_deep_settings_field(field, values)); + } + if !active_section.is_empty() { + body.push_str("
    "); + } + body +} + +fn render_deep_settings_field( + field: admin::DeepSettingsField, + values: &admin::DeepSettingsValues, +) -> String { + let name = field.form_name(); + let id = format!("deep-{name}"); + let value = values.form_value(field); + let control = match field.input_kind() { + admin::DeepSettingsInputKind::Text => format!( + r#""#, + html_escape::encode_double_quoted_attribute(&value), + ), + admin::DeepSettingsInputKind::Number => format!( + r#""#, + html_escape::encode_double_quoted_attribute(&value), + ), + admin::DeepSettingsInputKind::Boolean => { + let true_selected = if value == "true" { " selected" } else { "" }; + let false_selected = if value == "false" { " selected" } else { "" }; + format!( + r#""# + ) + } + }; + let helper = field.helper().map_or_else(String::new, |helper| { + format!( + r#"

    {}

    "#, + html_escape::encode_text(helper) + ) + }); + format!( + r#"
    {control}{helper}
    "#, + html_escape::encode_text(field.label()), + ) +} + +fn render_deep_settings_confirmation( + csrf: &str, + values: &admin::DeepSettingsValues, + changes: &[admin::DeepSettingsChange], + notice: Option<(&str, &str)>, +) -> String { + let notice_html = + notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let rows = changes + .iter() + .map(|change| { + format!( + r#"
  • {}
    {} -> {}
  • "#, + html_escape::encode_text(change.label), + html_escape::encode_text(&change.old_value), + html_escape::encode_text(&change.new_value), + ) + }) + .collect::>() + .join(""); + let hidden = render_deep_settings_hidden_fields(values); + format!( + r#"{notice_html}

    These settings are about to be changed

    Review the changed values before writing settings.toml. Saved changes require a RustPost restart before this running server uses them.

      {rows}
    {hidden}
    {hidden}
    "#, + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_double_quoted_attribute(csrf), + ) +} + +fn render_deep_settings_hidden_fields(values: &admin::DeepSettingsValues) -> String { + admin::DeepSettingsField::ALL + .iter() + .copied() + .fold(String::new(), |mut fields, field| { + let _ = write!( + fields, + r#""#, + field.form_name(), + html_escape::encode_double_quoted_attribute(&values.form_value(field)) + ); + fields + }) +} + +fn render_media_jobs_report(report: &admin::MediaJobsReport) -> String { + if report.total == 0 { + return r#"

    No media jobs yet.

    "#.to_owned(); + } + + let mut out = String::new(); + let pending_age = match ( + report.newest_pending_age_seconds, + report.oldest_pending_age_seconds, + ) { + (Some(newest), Some(oldest)) => { + format!( + "{} newest / {} oldest", + format_age(newest), + format_age(oldest) + ) + } + _ => "none".to_owned(), + }; + let _ = write!( + out, + r#"
    TotalPendingRunningSucceededFailedPending age
    {}{}{}{}{}{}
    "#, + report.total, + report.pending, + report.running, + report.succeeded, + report.failed, + html_escape::encode_text(&pending_age), + ); + + if report.recent_failures.is_empty() { + if report.failed == 0 { + out.push_str(r#"

    No recent media job failures.

    "#); + } + return out; + } + + out.push_str( + r#"

    Recent failures

    "#, + ); + for failure in &report.recent_failures { + let media = failure + .media_path + .as_deref() + .map(|path| compact_text(path, 48)) + .or_else(|| failure.media_id.map(|id| format!("#{id}"))) + .unwrap_or_else(|| "unknown".to_owned()); + let kind = failure.job_kind.as_deref().unwrap_or("media"); + let age = failure + .age_seconds + .map_or_else(|| "unknown".to_owned(), format_age); + let error = compact_text(&failure.error_summary, 80); + let _ = write!( + out, + r#""#, + failure.id, + html_escape::encode_text(&media), + html_escape::encode_text(kind), + html_escape::encode_text(&age), + html_escape::encode_text(&error), + ); + } + out.push_str("
    JobMediaKindAgeError
    #{}{}{}{}{}
    "); + out +} + +fn compact_text(input: &str, max_chars: usize) -> String { + let compact = input.split_whitespace().collect::>().join(" "); + if compact.chars().count() <= max_chars { + return compact; + } + + let take = max_chars.saturating_sub(3); + let mut shortened = compact.chars().take(take).collect::(); + shortened.push_str("..."); + shortened +} + +fn format_age(seconds: i64) -> String { + let seconds = seconds.max(0); + if seconds < 60 { + return format!("{seconds}s"); + } + let minutes = seconds / 60; + if minutes < 60 { + return format!("{minutes}m"); + } + let hours = minutes / 60; + if hours < 48 { + return format!("{hours}h"); + } + format!("{}d", hours / 24) +} + +async fn admin_backups( + State(state): State>, + headers: HeaderMap, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + backups_page(&state, &user, &csrf, None).await +} + +#[derive(Deserialize)] +struct BackupForm { + csrf: String, + include_tor_keys: Option, +} + +async fn admin_create_backup( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let settings = load_deep_settings(&state)?; + if !settings.backup.enabled { + return backups_page( + &state, + &user, + &csrf, + Some(("error", "Backups are disabled in settings.toml.")), + ) + .await; + } + let include_tor = form.include_tor_keys.is_some(); + let paths = state.paths.clone(); + let created = tokio::task::spawn_blocking(move || backup::create_backup(&paths, include_tor)) + .await + .map_err(|err| AppError::BadRequest(format!("backup task failed: {err}")))?; + let archive = match created { + Ok(archive) => archive, + Err(err) => { + tracing::warn!(error = %err, "manual backup failed"); + let message = public_backup_error("Backup failed", &err); + return backups_page(&state, &user, &csrf, Some(("error", &message))).await; + } + }; + admin::audit( + &state.pool, + user.id, + "create_backup", + archive + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("backup"), + ) + .await?; + let message = format!( + "Backup created: {}", + archive + .file_name() + .and_then(|name| name.to_str()) + .unwrap_or("archive") + ); + backups_page(&state, &user, &csrf, Some(("success", &message))).await +} + +async fn admin_download_backup( + State(state): State>, + headers: HeaderMap, + Path(filename): Path, +) -> AppResult { + require_admin(&state, &headers).await?; + let path = backup::backup_path_for_download(&state.paths, &filename) + .map_err(|_err| AppError::NotFound)?; + let metadata = tokio::fs::metadata(&path).await?; + let file = tokio::fs::File::open(&path).await?; + let stream = futures_util::stream::unfold(file, |mut file| async move { + let mut buffer = vec![0u8; 64 * 1024]; + match file.read(&mut buffer).await { + Ok(0) => None, + Ok(read) => { + buffer.truncate(read); + Some((Ok::(Bytes::from(buffer)), file)) + } + Err(error) => Some((Err(error), file)), + } + }); + let disposition = format!("attachment; filename=\"{}\"", filename.replace('"', "")); + let mut response = Body::from_stream(stream).into_response(); + response.headers_mut().insert( + header::CONTENT_TYPE, + HeaderValue::from_static("application/x-tar"), + ); + response.headers_mut().insert( + header::CONTENT_LENGTH, + HeaderValue::from_str(&metadata.len().to_string()) + .map_err(|err| AppError::BadRequest(err.to_string()))?, + ); + response.headers_mut().insert( + header::CONTENT_DISPOSITION, + HeaderValue::from_str(&disposition).map_err(|err| AppError::BadRequest(err.to_string()))?, + ); + Ok(response) +} + +async fn admin_backup_settings_update( + State(state): State>, + headers: HeaderMap, + Form(form): Form, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + validate_csrf(&state.pool, &headers, &form.csrf).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + let current = load_deep_settings(&state)?; + let values = match admin::parse_backup_settings_form(&form, ¤t) { + Ok(values) => values, + Err(err) => { + let message = err.to_string(); + return backups_page(&state, &user, &csrf, Some(("error", &message))).await; + } + }; + let updated = values.apply_to(¤t); + if let Err(err) = admin::write_backup_settings(&state.paths.settings_path, &updated) { + tracing::error!(error = %err, "failed to save backup settings"); + return backups_page( + &state, + &user, + &csrf, + Some(( + "error", + "Backup settings could not be saved. Check the server logs.", + )), + ) + .await; + } + admin::audit( + &state.pool, + user.id, + "update_backup_settings", + "settings.toml", + ) + .await?; + backups_page( + &state, + &user, + &csrf, + Some(( + "success", + "Backup settings saved. The scheduler reads settings.toml on its next check.", + )), + ) + .await +} + +async fn admin_restore_backup( + State(state): State>, + headers: HeaderMap, + multipart: Multipart, +) -> AppResult> { + let user = require_admin(&state, &headers).await?; + let csrf = form_csrf(&state, &headers).await.unwrap_or_default(); + // Staged restore uploads live under the runtime temp directory; keep + // cleanup from removing them while the restore is in flight. + let _operation = crate::runtime::begin_temp_operation(); + let upload = match parse_restore_upload(&state, &headers, multipart).await { + Ok(upload) => upload, + Err(err) => { + return backups_page(&state, &user, &csrf, Some(("error", &err.to_string()))).await; + } + }; + if upload.confirmation != "RESTORE" { + let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; + return backups_page( + &state, + &user, + &csrf, + Some(( + "error", + "Type RESTORE to confirm restoring from an uploaded backup.", + )), + ) + .await; + } + admin::audit( + &state.pool, + user.id, + "restore_backup_upload", + &upload.filename, + ) + .await?; + let paths = state.paths.clone(); + let archive_path = upload.archive_path.clone(); + let include_tor_keys = upload.include_tor_keys; + let restored = tokio::task::spawn_blocking(move || { + backup::restore_backup(&paths, &archive_path, include_tor_keys) + }) + .await + .map_err(|err| AppError::BadRequest(format!("restore task failed: {err}")))?; + let _remove_result = tokio::fs::remove_file(&upload.archive_path).await; + match restored { + Ok(report) => { + state + .restart_required + .store(true, std::sync::atomic::Ordering::Relaxed); + let safety = report + .pre_restore_backup + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .unwrap_or("pre-restore backup"); + let message = format!( + "Restore completed. Restart RustPost so the running process reopens the restored database. Safety backup: {safety}." + ); + backups_page(&state, &user, &csrf, Some(("success", &message))).await + } + Err(err) => { + tracing::warn!(error = %err, "restore upload failed"); + let message = public_backup_error("Restore failed", &err); + backups_page(&state, &user, &csrf, Some(("error", &message))).await + } + } +} + +struct ParsedRestoreUpload { + archive_path: PathBuf, + filename: String, + include_tor_keys: bool, + confirmation: String, +} + +async fn parse_restore_upload( + state: &AppState, + headers: &HeaderMap, + mut multipart: Multipart, +) -> AppResult { + let mut csrf_validated = false; + let mut include_tor_keys = false; + let mut confirmation = String::new(); + let mut archive_path = None; + let mut filename = "uploaded-backup.tar".to_owned(); + while let Some(field) = multipart + .next_field() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + let Some(name) = field.name().map(ToOwned::to_owned) else { + continue; + }; + match name.as_str() { + "csrf" => { + let token = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + validate_csrf(&state.pool, headers, &token).await?; + csrf_validated = true; + } + "include_tor_keys" => { + include_tor_keys = field.text().await.is_ok_and(|value| value == "true"); + } + "restore_confirm" => { + confirmation = field + .text() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))?; + } + "backup" if field.file_name().is_some() => { + if !csrf_validated { + return Err(AppError::Forbidden); + } + field + .file_name() + .filter(|name| !name.trim().is_empty()) + .unwrap_or("uploaded-backup.tar") + .clone_into(&mut filename); + if !std::path::Path::new(&filename) + .extension() + .is_some_and(|ext| ext.eq_ignore_ascii_case("tar")) + { + return Err(AppError::BadRequest( + "Choose a .tar backup archive.".to_owned(), + )); + } + let path = state + .paths + .tmp_dir + .join(format!("restore-upload-{}.tar", Uuid::new_v4().simple())); + write_multipart_field_to_file(field, &path, None).await?; + archive_path = Some(path); + } + _ => {} + } + } + if !csrf_validated { + return Err(AppError::Forbidden); + } + Ok(ParsedRestoreUpload { + archive_path: archive_path.ok_or_else(|| { + AppError::BadRequest("Choose a backup archive to restore.".to_owned()) + })?, + filename, + include_tor_keys, + confirmation, + }) +} + +/// Streams one multipart field to `path` and returns the bytes written. +/// +/// When `max_bytes` is set the limit is enforced for every chunk as it +/// arrives, so an oversized upload is rejected before the whole body is +/// buffered or stored. Uploads must never be buffered in memory. +async fn write_multipart_field_to_file( + mut field: axum::extract::multipart::Field<'_>, + path: &std::path::Path, + max_bytes: Option, +) -> AppResult { + if let Some(parent) = path.parent() { + tokio::fs::create_dir_all(parent).await?; + } + let mut output = tokio::fs::File::create(path).await?; + let mut written = 0u64; + while let Some(chunk) = field + .chunk() + .await + .map_err(|err| AppError::BadRequest(err.to_string()))? + { + written = written.saturating_add(u64::try_from(chunk.len()).unwrap_or(u64::MAX)); + if let Some(limit) = max_bytes + && written > limit + { + return Err(upload_limit_error(limit)); + } + output.write_all(&chunk).await?; + } + output.flush().await?; + Ok(written) +} + +fn upload_limit_error(limit: u64) -> AppError { + AppError::PayloadTooLarge(format!( + "the uploaded archive is larger than the configured {} MiB limit", + limit / (1024 * 1024) + )) +} + +async fn backups_page( + state: &AppState, + user: &CurrentUser, + csrf: &str, + notice: Option<(&str, &str)>, +) -> AppResult> { + let settings = load_deep_settings(state)?; + let archives = backup::list_backups(&state.paths)?; + let notice_html = + notice.map_or_else(String::new, |(kind, message)| render::notice(kind, message)); + let body = format!( + "{}{}{}{}{}{}", + notice_html, + render::page_header( + "Backups", + "Create, download, schedule, and restore full-site runtime backups." + ), + render_manual_backup_panel(csrf, &settings), + render_automatic_backup_panel(csrf, &settings), + render_backup_history(&archives, backup::operation_in_progress(&state.paths)), + render_restore_panel(csrf), + ); + Ok(Html( + page_layout(state, Some(user), Some(csrf), "Backups", &body).await?, + )) +} + +fn render_manual_backup_panel(csrf: &str, settings: &Settings) -> String { + let disabled = if settings.backup.enabled { + "" + } else { + " disabled" + }; + format!( + r#"

    Manual backup

    Archives are written under the private backup directory and are available only to admins.

    Leave Tor keys unchecked unless the archive storage is encrypted and access-controlled.

    "#, + html_escape::encode_double_quoted_attribute(csrf), + ) +} + +fn render_automatic_backup_panel(csrf: &str, settings: &Settings) -> String { + let values = admin::BackupSettingsValues::from_settings(settings); + format!( + r#"

    Automatic backups

    {}
    {}

    Set 0 to disable age cleanup. Manual and pre-restore backups are not pruned.

    {}
    "#, + html_escape::encode_double_quoted_attribute(csrf), + bool_select("backup-enabled", "enabled", values.enabled), + bool_select( + "backup-auto-enabled", + "automatic_enabled", + values.automatic_enabled + ), + values.automatic_interval_minutes, + values.retention_keep_last, + values.retention_max_age_days, + bool_select( + "backup-auto-tor", + "automatic_include_tor_keys", + values.automatic_include_tor_keys + ), + ) +} + +fn bool_select(id: &str, name: &str, value: bool) -> String { + let true_selected = if value { " selected" } else { "" }; + let false_selected = if value { "" } else { " selected" }; + format!( + r#""#, + html_escape::encode_double_quoted_attribute(id), + html_escape::encode_double_quoted_attribute(name), + ) +} + +fn render_backup_history( + archives: &[backup::BackupArchiveInfo], + operation_running: bool, +) -> String { + let status = if archives.is_empty() { + r#"

    No backups have been created yet.

    "#.to_owned() + } else { + let rows = archives + .iter() + .take(20) + .map(|archive| { + let created = archive.created_at.as_deref().unwrap_or("unknown"); + let tor_keys = archive + .tor_keys_included + .map_or("unknown", |included| if included { "included" } else { "excluded" }); + let kind = if archive.automatic { "automatic" } else { "manual" }; + let manifest = if archive.manifest_valid { "valid" } else { "unreadable" }; + format!( + r#"{}{}{}{}{}{}Download"#, + html_escape::encode_text(&archive.filename), + html_escape::encode_text(kind), + html_escape::encode_text(created), + format_bytes(archive.size), + html_escape::encode_text(tor_keys), + html_escape::encode_text(manifest), + html_escape::encode_double_quoted_attribute(&archive.filename), + ) + }) + .collect::>() + .join(""); + format!( + r#"{rows}
    ArchiveKindCreatedSizeTor keysManifestAction
    "# + ) + }; + let running = if operation_running { + render::notice( + "info", + "A backup or restore operation is currently running.", + ) + } else { + String::new() + }; + format!( + r#"

    Recent backups

    {running}{status}
    "# + ) +} + +fn render_restore_panel(csrf: &str) -> String { + format!( + r#"

    Danger area

    Restore from upload

    Restore validates the manifest, hashes, paths, settings, and SQLite integrity in a staging directory before replacing live runtime files. A pre-restore safety backup is created first.

    "#, + html_escape::encode_double_quoted_attribute(csrf), + ) +} + +fn public_backup_error(prefix: &str, error: &anyhow::Error) -> String { + let detail = error.to_string(); + if detail.contains('/') || detail.contains('\\') { + format!("{prefix}. Check the server logs for details.") + } else { + format!("{prefix}: {detail}") + } +} + +fn format_bytes(bytes: u64) -> String { + const KIB: u64 = 1024; + const MIB: u64 = KIB * 1024; + const GIB: u64 = MIB * 1024; + if bytes >= GIB { + return format_scaled_bytes(bytes, GIB, "GiB"); + } + if bytes >= MIB { + return format_scaled_bytes(bytes, MIB, "MiB"); + } + if bytes >= KIB { + return format_scaled_bytes(bytes, KIB, "KiB"); + } + format!("{bytes} B") +} + +fn format_scaled_bytes(bytes: u64, unit: u64, suffix: &str) -> String { + let tenths = u128::from(bytes) * 10 / u128::from(unit); + format!("{}.{:01} {suffix}", tenths / 10, tenths % 10) +} + +fn small_form(action: &str, csrf: &str, label: &str, title: &str) -> String { + format!( + r#"
    "#, + html_escape::encode_double_quoted_attribute(action), + html_escape::encode_double_quoted_attribute(csrf), + html_escape::encode_double_quoted_attribute(title), + html_escape::encode_double_quoted_attribute(title), + html_escape::encode_text(label) + ) +} + +async fn require_user(state: &AppState, headers: &HeaderMap) -> AppResult { + current(state, headers).await?.ok_or(AppError::Unauthorized) +} + +async fn require_active_user(state: &AppState, headers: &HeaderMap) -> AppResult { + let user = require_user(state, headers).await?; + if user.is_suspended { + return Err(AppError::Forbidden); + } + Ok(user) +} + +async fn require_admin(state: &AppState, headers: &HeaderMap) -> AppResult { + let user = require_active_user(state, headers).await?; + if !user.is_admin { + return Err(AppError::Forbidden); + } + Ok(user) +} + +async fn validate_csrf(pool: &SqlitePool, headers: &HeaderMap, token: &str) -> AppResult<()> { + csrf::validate(pool, headers, token) + .await + .map_err(|_csrf_err| AppError::Forbidden) +} + +/// Reads the session's current CSRF token and rotates it for the next form. +/// +/// Two concurrent page loads for one session can both read the same stored +/// hash; only one optimistic update wins. Retrying lets the loser read the new +/// hash and rotate again instead of rendering a form with no token. +async fn form_csrf(state: &AppState, headers: &HeaderMap) -> Option { + const ROTATION_ATTEMPTS: usize = 3; + let token = auth::session_cookie(headers)?; + let token_hash = auth::hash_token(&token); + for _attempt in 0..ROTATION_ATTEMPTS { + let (stored_hash, previous_hashes): (String, Option) = state + .pool + .call({ + let token_hash = token_hash.clone(); + 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", + [token_hash], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .optional() + .map_err(Into::into) + } + }) + .await + .ok()??; + let previous_hashes = csrf_history_with(&stored_hash, previous_hashes.as_deref()); + let plain = auth::secure_token(); + let new_hash = auth::hash_token(&plain); + let session_token_hash = token_hash.clone(); + let updated = state + .pool + .call(move |conn| { + let changed = conn.execute( + "UPDATE sessions SET csrf_token_hash = ?, previous_csrf_token_hash = ? WHERE token_hash = ? AND csrf_token_hash = ?", + params![new_hash, previous_hashes, session_token_hash, stored_hash], + )?; + Ok(changed == 1) + }) + .await + .ok()?; + if updated { + return Some(plain); + } + } + None +} + +fn csrf_history_with(current_hash: &str, previous_hashes: Option<&str>) -> String { + std::iter::once(current_hash) + .chain( + previous_hashes + .into_iter() + .flat_map(str::lines) + .filter(|hash| !hash.is_empty() && *hash != current_hash), + ) + .take(CSRF_TOKEN_HISTORY_LIMIT.saturating_sub(1)) + .collect::>() + .join("\n") +} + +async fn user_post_relation_exists( + pool: &SqlitePool, + table: &str, + user_id: i64, + post_id: i64, +) -> AppResult { + let sql = format!("SELECT 1 FROM {table} WHERE user_id = ? AND post_id = ?"); + Ok(pool + .call(move |conn| { + Ok(conn + .query_row(&sql, params![user_id, post_id], |_| Ok(())) + .optional()? + .is_some()) + }) + .await?) +} + +fn ip_actor(addr: SocketAddr) -> String { + format!("ip:{}", addr.ip()) +} + +fn user_actor(user_id: i64) -> String { + format!("user:{user_id}") +} + +#[cfg(test)] +mod tests { + use super::*; + use flate2::read::GzDecoder; + use std::io::Read as _; + use std::path::PathBuf; + + struct TestServer { + base_url: String, + data_dir: PathBuf, + pool: SqlitePool, + registration_captcha: RegistrationCaptchaStore, + _task: tokio::task::JoinHandle<()>, + _temp: tempfile::TempDir, + } + + struct TestResponse { + status: u16, + headers: Vec<(String, String)>, + body_bytes: Vec, + body: String, + } + + #[test] + fn csrf_history_is_bounded_and_keeps_recent_hashes() { + let prior = (1..CSRF_TOKEN_HISTORY_LIMIT + 3) + .map(|idx| format!("token-{idx}")) + .collect::>() + .join("\n"); + + let history = csrf_history_with("current", Some(&prior)); + let hashes = history.lines().collect::>(); + + assert_eq!(hashes.len(), CSRF_TOKEN_HISTORY_LIMIT - 1); + assert_eq!(hashes[0], "current"); + assert_eq!(hashes[1], "token-1"); + assert_eq!(hashes[CSRF_TOKEN_HISTORY_LIMIT - 2], "token-30"); + } + + #[test] + fn upload_body_limit_covers_configured_media_mix() { + let settings = Settings::default(); + let remaining_media = + u64::try_from(settings.posts.max_media_per_post.saturating_sub(1)).unwrap_or(u64::MAX); + let expected_payload = settings.media.max_video_size.saturating_add( + settings + .media + .max_image_size + .saturating_mul(remaining_media), + ); + + assert!(u64::try_from(upload_body_limit(&settings)).unwrap_or(0) > expected_payload); + } + + #[test] + fn compact_text_collapses_whitespace_and_truncates() { + let compact = compact_text( + "/uploads/originals/a/very/long/path.png\nffmpeg stderr repeated detail", + 24, + ); + + assert_eq!(compact, "/uploads/originals/a/..."); + } + + #[test] + fn media_jobs_report_keeps_healthy_state_short() { + let report = admin::MediaJobsReport { + total: 3, + succeeded: 3, + ..admin::MediaJobsReport::default() + }; + + let output = render_media_jobs_report(&report); + + assert!(output.contains("Total")); + assert!(output.contains("3")); + assert!(output.contains("No recent media job failures.")); + assert!(!output.contains("Recent failures")); + assert!(!output.contains("
    "));
    +    }
    +
    +    #[test]
    +    fn media_jobs_report_shows_compact_recent_failures() {
    +        let report = admin::MediaJobsReport {
    +            total: 8,
    +            pending: 1,
    +            running: 1,
    +            succeeded: 4,
    +            failed: 2,
    +            newest_pending_age_seconds: Some(90),
    +            oldest_pending_age_seconds: Some(3_900),
    +            recent_failures: vec![admin::MediaJobFailure {
    +                id: 42,
    +                media_id: Some(7),
    +                media_path: Some("/media/uploads/a/really/long/path/that/should/not/dominate/report/video.webm".to_owned()),
    +                job_kind: Some("video".to_owned()),
    +                age_seconds: Some(3_600),
    +                error_summary: "ffmpeg failed\nwith a very long diagnostic that should be clipped before it fills the admin table".to_owned(),
    +            }],
    +        };
    +
    +        let output = render_media_jobs_report(&report);
    +
    +        assert!(output.contains("Recent failures"));
    +        assert!(output.contains("#42"));
    +        assert!(output.contains("video"));
    +        assert!(output.contains("1h"));
    +        assert!(output.contains("1m newest / 1h oldest"));
    +        assert!(output.contains("..."));
    +        assert!(!output.contains("should be clipped before it fills"));
    +        assert!(!output.contains("
    "));
    +    }
    +
    +    #[test]
    +    fn delete_return_target_rejects_self_referential_confirmation_paths() {
    +        assert_eq!(safe_delete_return_target("/posts/42/delete", 42), None);
    +        assert_eq!(
    +            safe_delete_return_target("/posts/42/delete#post-42", 42),
    +            None
    +        );
    +        assert_eq!(
    +            safe_delete_return_target("http://127.0.0.1:18080/posts/42/delete?return_to=/home", 42),
    +            None
    +        );
    +        assert_eq!(safe_delete_return_target("/posts/42#post-42", 42), None);
    +        assert_eq!(
    +            safe_delete_return_target("/home#post-42", 42),
    +            Some("/home#post-42".to_owned())
    +        );
    +    }
    +
    +    #[tokio::test]
    +    async fn non_admin_cannot_access_admin_users() {
    +        let server = spawn_test_server_with_admin().await;
    +        let member_cookie = register_test_user(&server, "member").await;
    +
    +        let response = get_with_cookie(&server, "/admin/users", &member_cookie).await;
    +
    +        assert_eq!(response.status, 403);
    +    }
    +
    +    #[tokio::test]
    +    async fn cross_site_login_and_registration_posts_are_rejected() {
    +        let server = spawn_test_server_with_admin().await;
    +
    +        let login = request(
    +            &server.base_url,
    +            "POST",
    +            "/login",
    +            &[
    +                ("content-type", "application/x-www-form-urlencoded"),
    +                ("origin", "https://attacker.example"),
    +                ("sec-fetch-site", "cross-site"),
    +            ],
    +            b"username=siteowner&password=very%20secure%20password".to_vec(),
    +        )
    +        .await;
    +        let registration = request(
    +            &server.base_url,
    +            "POST",
    +            "/register",
    +            &[
    +                ("content-type", "application/x-www-form-urlencoded"),
    +                ("origin", "https://attacker.example"),
    +                ("sec-fetch-site", "cross-site"),
    +            ],
    +            b"username=forced&password=very%20secure%20password&confirm_password=very%20secure%20password".to_vec(),
    +        )
    +        .await;
    +
    +        assert_eq!(login.status, 403);
    +        assert_eq!(registration.status, 403);
    +        let forced_accounts: i64 = server
    +            .pool
    +            .call(|conn| {
    +                Ok(conn.query_row(
    +                    "SELECT COUNT(*) FROM users WHERE normalized_username = 'forced'",
    +                    [],
    +                    |row| row.get(0),
    +                )?)
    +            })
    +            .await
    +            .expect("forced account count");
    +        assert_eq!(forced_accounts, 0);
    +    }
    +
    +    #[tokio::test]
    +    async fn suspended_admin_session_cannot_access_admin_routes() {
    +        let server = spawn_test_server_with_admin().await;
    +        let admin_cookie = admin_session_cookie(&server).await;
    +        server
    +            .pool
    +            .call(|conn| {
    +                conn.execute(
    +                    "UPDATE users SET is_suspended = 1 WHERE normalized_username = 'siteowner'",
    +                    [],
    +                )?;
    +                Ok(())
    +            })
    +            .await
    +            .expect("suspend admin");
    +
    +        let response = get_with_cookie(&server, "/admin/users", &admin_cookie).await;
    +
    +        assert_eq!(response.status, 403);
    +    }
    +
    +    #[tokio::test]
    +    async fn admin_users_page_shows_expanded_user_details() {
    +        let server = spawn_test_server_with_admin().await;
    +        let admin_cookie = admin_session_cookie(&server).await;
    +        let settings = Settings::default();
    +        let alice = auth::register_user(
    +            &server.pool,
    +            &settings,
    +            "alice",
    +            "very secure password",
    +            false,
    +        )
    +        .await
    +        .expect("alice");
    +        let reporter = auth::register_user(
    +            &server.pool,
    +            &settings,
    +            "reporter",
    +            "very secure password",
    +            false,
    +        )
    +        .await
    +        .expect("reporter");
    +        let post = social::create_post(
    +            &server.pool,
    +            &settings,
    +            Some(alice),
    +            "expanded admin detail post",
    +            None,
    +            &[],
    +        )
    +        .await
    +        .expect("post");
    +        server
    +            .pool
    +            .call(move |conn| {
    +                conn.execute(
    +                    "UPDATE users SET display_name = 'Alice Admin' WHERE id = ?",
    +                    [alice],
    +                )?;
    +                conn.execute(
    +                    "INSERT INTO media (owner_user_id, original_filename, stored_path, public_path, mime_type, media_kind, byte_len) VALUES (?, 'alice.png', '/tmp/alice.png', '/uploads/images/alice.png', 'image/png', 'image', 12)",
    +                    [alice],
    +                )?;
    +                conn.execute(
    +                    "INSERT INTO reports (reporter_user_id, post_id, reason) VALUES (?, ?, 'spam')",
    +                    params![reporter, post],
    +                )?;
    +                Ok(())
    +            })
    +            .await
    +            .expect("seed details");
    +
    +        let response = get_with_cookie(&server, "/admin/users", &admin_cookie).await;
    +
    +        assert_eq!(response.status, 200);
    +        assert!(response.body.contains("Alice Admin"));
    +        assert!(response.body.contains("@alice"));
    +        assert!(response.body.contains("Created"));
    +        assert!(response.body.contains("Last post"));
    +        assert!(response.body.contains("Total posts"));
    +        assert!(response.body.contains("Uploaded media"));
    +        assert!(response.body.contains("Reports on posts"));
    +        assert!(response.body.contains("Moderation actions"));
    +        assert!(response.body.contains(">1"));
    +    }
    +
    +    #[tokio::test]
    +    async fn admin_users_username_search_returns_expected_users() {
    +        let server = spawn_test_server_with_admin().await;
    +        let admin_cookie = admin_session_cookie(&server).await;
    +        let settings = Settings::default();
    +        auth::register_user(
    +            &server.pool,
    +            &settings,
    +            "alice",
    +            "very secure password",
    +            false,
    +        )
    +        .await
    +        .expect("alice");
    +        auth::register_user(
    +            &server.pool,
    +            &settings,
    +            "bob",
    +            "very secure password",
    +            false,
    +        )
    +        .await
    +        .expect("bob");
    +
    +        let response = get_with_cookie(&server, "/admin/users?user_q=ali", &admin_cookie).await;
    +
    +        assert_eq!(response.status, 200);
    +        assert!(response.body.contains("@alice"));
    +        assert!(!response.body.contains("@bob"));
    +        assert!(response.body.contains("Matched name"));
    +    }
    +
    +    #[tokio::test]
    +    async fn admin_users_post_keyword_search_returns_matching_accounts() {
    +        let server = spawn_test_server_with_admin().await;
    +        let admin_cookie = admin_session_cookie(&server).await;
    +        let settings = Settings::default();
    +        let alice = auth::register_user(
    +            &server.pool,
    +            &settings,
    +            "alice",
    +            "very secure password",
    +            false,
    +        )
    +        .await
    +        .expect("alice");
    +        let bob = auth::register_user(
    +            &server.pool,
    +            &settings,
    +            "bob",
    +            "very secure password",
    +            false,
    +        )
    +        .await
    +        .expect("bob");
    +        social::create_post(
    +            &server.pool,
    +            &settings,
    +            Some(alice),
    +            "admin keyword needle