diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd8d8df..3c43ff37 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,14 +2,17 @@ All notable changes to RustChan will be documented in this file. -## RustChan 1.2.2 +## RustChan 1.3.0 - Replaced browser proof-of-work posting CAPTCHA with server-generated image CAPTCHA challenges. - Improved secure-cookie handling across HTTP, HTTPS, trusted-proxy HTTPS, admin sessions, board access cookies, CSRF cookies, and owned-post cookies. - Added no-JS fallbacks and accessibility polish for posting, reporting, catalog actions, board preferences, moderation controls, and own-post edit/delete flows. - Hardened upload handling for empty file controls, zero-byte named uploads, empty thumbnail payloads, invalid media, and cross-board media deduplication. +- Tightened post upload validation so rejected media now returns semantic HTTP statuses for JS and no-JS submissions, exact-limit PDFs are accepted while cap + 1 PDFs are rejected, and malformed ADTS AAC is rejected before storage or background job creation. +- Fixed late-cycle no-JS and pending-media regressions: Firefox no-JS theme changes persist through safe local redirects, and thread update controls expose a stable `data-action="fetch-updates"` hook for pending-media refreshes. - Improved activity badge cache behavior, especially on mobile WebKit and browser back/forward navigation. - Hardened settings validation so invalid config values fail closed instead of silently falling back. +- Squashed the pre-release internal database migration ladder into a clean `1.3.0` baseline schema. Fresh databases now install that baseline directly, structurally matching in-development databases are stamped as schema version `1.3.0`, and partial or unknown schemas fail closed with diagnostics instead of blind migration attempts. - Polished responsive layout, long-content wrapping, modal focus behavior, ESC handling, touch targets, and light-theme error contrast. - Updated backup UI metadata handling and dynamic split-part options. - Refreshed README screenshots and release documentation. @@ -126,7 +129,8 @@ All notable changes to RustChan will be documented in this file. - Theme CSS internals are cleaner and safer to maintain: the fixed footer now uses one shared height variable with safe-area-aware body padding, Frutiger Aero and NeonCubicle now share one glass-pill navigation implementation, and the Forest theme now centralizes repeated surface, link, button, and input colors behind theme-scoped variables. - Mobile header polish is tighter on board pages: the search bar now stretches to the same visual rails as the Home and Boards controls instead of ending short on narrow screens. - The theme picker now lives in a footer-docked control bar on both desktop and mobile, giving theme switching one consistent home and keeping it from floating over page content. -- Backup and media-processing observability are stronger: posts now expose pending and failed async media state, `/readyz` and `/metrics` report media backlog, backup freshness, and maintenance activity, and the admin panel surfaces backup verification health instead of assuming saved ZIPs are restorable. +- Backup and media-processing observability are stronger: posts now expose pending and failed async media state, opt-in detailed `/readyz` and `/metrics` report media backlog, backup freshness, and maintenance activity, and the admin panel surfaces backup verification health instead of assuming saved ZIPs are restorable. +- Public observability is safer by default for internet and onion deployments: `/healthz` remains public, `/readyz` exposes only readiness status unless `public_readiness_details = true`, and `/metrics` is disabled unless `public_metrics_enabled = true`. - Heavy admin maintenance now coordinates through a shared maintenance gate and less aggressive background scheduling, so backups, restores, integrity checks, repair, and scheduled `VACUUM`/WAL work are less likely to pile onto live request traffic or each other. - Full backup recovery is now more flexible without adding scheduler clutter: new full backups record the boards they contain, and the admin panel can derive a single-board restore or downloadable board backup directly from a saved full-site archive. - Long media filenames now keep post layouts tidier without hiding the real upload name: thread and reply views truncate only the displayed stem, preserve the extension in the visible link text, and still expose the full original filename through the link tooltip. diff --git a/Cargo.lock b/Cargo.lock index 02cc8dba..0aa3489a 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,9 +184,9 @@ checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" [[package]] name = "arti-client" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16739ee6ad4ee11073a8d12c34a911ea72977c5ce4abbb21a1f7cf5955f22a01" +checksum = "7ffc89b08e6a475a83d70e52f0ede139a433eb46e986a6cee6eeaa1b73297bf1" dependencies = [ "async-trait", "cfg-if", @@ -202,7 +202,7 @@ dependencies = [ "libc", "once_cell", "postage", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "tempfile", @@ -401,9 +401,9 @@ dependencies = [ [[package]] name = "async-web-client" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "37381fb4fad3cd9b579628c21a58f528ef029d1f072d10f16cb9431aa2236d29" +checksum = "8caf502b44d6d4be6154ac33af012cbb5fef11e6066edcfb42834217fbaf501b" dependencies = [ "async-http-codec", "async-net", @@ -413,6 +413,7 @@ dependencies = [ "lazy_static", "log", "rustls-pki-types", + "serde", "thiserror 1.0.69", "webpki-roots 0.26.11", ] @@ -642,9 +643,9 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.11.1" +version = "2.13.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" [[package]] name = "bitvec" @@ -711,9 +712,9 @@ dependencies = [ [[package]] name = "brotli" -version = "8.0.2" +version = "8.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd8b9603c7aa97359dbd97ecf258968c95f3adddd6db2f7e7a5bef101c84560" +checksum = "8119e4516436f5708bbc474a9d395bf12f1b5395e93a92a56e647ac3388c8610" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -722,9 +723,9 @@ dependencies = [ [[package]] name = "brotli-decompressor" -version = "5.0.0" +version = "5.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "874bb8112abecc98cbd6d81ea4fa7e94fb9449648c93cc89aa40c81c24d7de03" +checksum = "5962523e1b92ce1b5e793d9169b9943eece10d39f62550bc04bb605d75b94924" dependencies = [ "alloc-no-stdlib", "alloc-stdlib", @@ -807,9 +808,9 @@ checksum = "beae2cb9f60bc3f21effaaf9c64e51f6627edd54eedc9199ba07f519ef2a2101" [[package]] name = "cc" -version = "1.2.62" +version = "1.2.63" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +checksum = "556e016178bb5662a08681bbe0f00f8e17631781a4dfc8c45e466e4b185ec27f" dependencies = [ "find-msvc-tools", "jobserver", @@ -836,9 +837,9 @@ dependencies = [ [[package]] name = "chrono" -version = "0.4.44" +version = "0.4.45" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" dependencies = [ "iana-time-zone", "js-sys", @@ -1084,7 +1085,7 @@ version = "0.29.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b9f2e4c67f833b660cdb0a3523065869fb35570177239812ed4c905aeff87b" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crossterm_winapi", "derive_more", "document-features", @@ -1502,9 +1503,9 @@ dependencies = [ [[package]] name = "displaydoc" -version = "0.2.5" +version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +checksum = "1ac70aa55017e108007fbaf5aa0f54b021c98f92ff8af59d42eda9da96e3dd4f" dependencies = [ "proc-macro2", "quote", @@ -1847,9 +1848,9 @@ dependencies = [ [[package]] name = "fs-mistrust" -version = "0.14.1" +version = "0.14.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9f5ac9f88fd18733e0f9ce1f4a95c40eb1d4f83131bf1472e81d1f128fefb7c2" +checksum = "9cfebc7c6bb65d327ded064db65cd260b6c418c27ae790318650cfa2a81bf33f" dependencies = [ "derive_builder_fork_arti", "dirs", @@ -1857,6 +1858,7 @@ dependencies = [ "pwd-grp", "serde", "thiserror 2.0.18", + "void", "walkdir", ] @@ -1866,35 +1868,15 @@ version = "1.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" -[[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.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75f62cb7d296f7d1fabdce7291281d9f0147b06a6e79afae05e1230eab667d85" +checksum = "e77b8a36ee17f9079b53db7281de9b16a8fb2bc97a7cd1e41a2998f3a837af7a" dependencies = [ - "fslock-arti-fork", + "libc", "thiserror 2.0.18", - "winapi", + "windows-sys 0.61.2", ] [[package]] @@ -2188,9 +2170,9 @@ checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" [[package]] name = "hashlink" -version = "0.11.0" +version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ea0b22561a9c04a7cb1a302c013e0259cd3b4bb619f145b32f72b8b4bcbed230" +checksum = "824e001ac4f3012dd16a264bec811403a67ca9deb6c102fc5049b32c4574b35f" dependencies = [ "hashbrown 0.16.1", ] @@ -2239,9 +2221,9 @@ checksum = "f558a64ac9af88b5ba400d99b579451af0d39c6d360980045b91aac966d705e2" [[package]] name = "http" -version = "1.4.0" +version = "1.4.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +checksum = "8be7462df143984c4598a256ef469b251d7d7f9e271135073e78fc535414f3d0" dependencies = [ "bytes", "itoa", @@ -2315,9 +2297,9 @@ dependencies = [ [[package]] name = "hyper" -version = "1.9.0" +version = "1.10.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +checksum = "55281c53a1894c864990125767da440a4e630446785086f52523b20033b74498" dependencies = [ "atomic-waker", "bytes", @@ -2589,11 +2571,11 @@ dependencies = [ [[package]] name = "inotify" -version = "0.11.1" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bd5b3eaf1a28b758ac0faa5a4254e8ab2705605496f1b1f3fbbc3988ad73d199" +checksum = "533e68a5842e734946fe159fb03fc9bbbb254f590dd0d8ad321ae5ff7beca2c1" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "inotify-sys", "libc", ] @@ -2763,9 +2745,9 @@ dependencies = [ [[package]] name = "kqueue" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eac30106d7dce88daf4a3fcb4879ea939476d5074a9b7ddd0fb97fa4bed5596a" +checksum = "273c0752728918e0ac4976f2b275b6fefb9ecd400585dec929419f3844cd87b5" dependencies = [ "kqueue-sys", "libc", @@ -2777,7 +2759,7 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "07293a4e297ac234359b510362495713f75ea345d5307140414f20c69ffeb087" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "libc", ] @@ -2810,9 +2792,9 @@ checksum = "b6d2cec3eae94f9f509c767b45932f1ada8350c4bdb85af2fcab4a3c14807981" [[package]] name = "libredox" -version = "0.1.16" +version = "0.1.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" +checksum = "f02ab6bace2054fb888a3c16f990117b579d14a3088e472d63c6011fa185c9d3" dependencies = [ "libc", ] @@ -2869,9 +2851,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.29" +version = "0.4.32" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a" [[package]] name = "matchers" @@ -2890,9 +2872,9 @@ checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" [[package]] name = "memchr" -version = "2.8.0" +version = "2.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" +checksum = "6b947ae49db0d222b1dbc6b113ce7248a3fc3a6ca21b696717bfc000ba4484d8" [[package]] name = "memmap2" @@ -2949,9 +2931,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.0" +version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +checksum = "02bd0af71c67b473010cbbc60715ee815645a4dc942899111f494b4b737d6fda" dependencies = [ "libc", "log", @@ -3014,7 +2996,7 @@ version = "8.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4d3d07927151ff8575b7087f245456e549fea62edf0ec4e565a5ee50c8402bc3" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "inotify", "kqueue", "libc", @@ -3031,7 +3013,7 @@ version = "2.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42b8cfee0e339a0337359f3c88165702ac6e600dc01c0cc9579a92d62b08477a" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3142,7 +3124,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3468,7 +3450,7 @@ version = "0.18.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "crc32fast", "fdeflate", "flate2", @@ -3564,7 +3546,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.11+spec-1.1.0", + "toml_edit 0.25.12+spec-1.1.0", ] [[package]] @@ -3722,6 +3704,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" @@ -3748,13 +3740,13 @@ 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]] @@ -3799,7 +3791,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", ] [[package]] @@ -3864,9 +3856,9 @@ checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" [[package]] name = "reqwest" -version = "0.13.3" +version = "0.13.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" +checksum = "219c5811de6525e5416c7d5d53bb656d3afdbc6c5af816e0802bcfa42dbdc1c3" dependencies = [ "base64 0.22.1", "bytes", @@ -3900,6 +3892,15 @@ dependencies = [ "web-sys", ] +[[package]] +name = "reseeding_rng" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35d8fa137e1f0bbc1139893fcf4fff5f099d76658e6da2b10fadd04f0cadc2d4" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "retry-error" version = "0.12.0" @@ -3980,7 +3981,7 @@ version = "0.39.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0d2b0146dd9661bf67bb107c0bb2a55064d556eeb3fc314151b957f313bcd4e" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "fallible-iterator", "fallible-streaming-iterator", "hashlink", @@ -4001,7 +4002,7 @@ dependencies = [ [[package]] name = "rustchan" -version = "1.2.2" +version = "1.3.0" dependencies = [ "anyhow", "argon2", @@ -4041,7 +4042,7 @@ dependencies = [ "serde", "serde_json", "sha2 0.11.0", - "sha3 0.11.0", + "sha3 0.12.0", "subtle", "tempfile", "thiserror 2.0.18", @@ -4078,7 +4079,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "errno", "libc", "linux-raw-sys", @@ -4103,9 +4104,9 @@ dependencies = [ [[package]] name = "rustls-acme" -version = "0.15.2" +version = "0.15.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8e44af6e70877950d8c30b7c0c86fca352d61d21d8b93174a0d4f84918e6065b" +checksum = "b9c70a17ecb067d5067565a16a2e0f26a4a2ea0924f49739d558c45186facc75" dependencies = [ "async-io", "async-trait", @@ -4131,9 +4132,9 @@ dependencies = [ [[package]] name = "rustls-native-certs" -version = "0.8.3" +version = "0.8.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "612460d5f7bea540c490b2b6395d8e34a953e52b491accd6c86c8164c5932a63" +checksum = "dab5152771c58876a2146916e53e35057e1a4dfa2b9df0f0305b07f611fdea4d" dependencies = [ "openssl-probe", "rustls-pki-types", @@ -4306,7 +4307,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "core-foundation", "core-foundation-sys", "libc", @@ -4445,9 +4446,9 @@ dependencies = [ [[package]] name = "serde_with" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" +checksum = "76a5c54c7310e7b8b9577c286d7e399ddd876c3e12b3ed917a8aabc4b96e9e8c" dependencies = [ "base64 0.22.1", "bs58", @@ -4465,9 +4466,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.20.0" +version = "3.21.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" +checksum = "84d57bc0c8b9a17920c178daa6bb924850d54a9c97ab45194bb8c17ad66bb660" dependencies = [ "darling 0.23.0", "proc-macro2", @@ -4518,16 +4519,6 @@ dependencies = [ "keccak 0.1.6", ] -[[package]] -name = "sha3" -version = "0.11.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "be176f1a57ce4e3d31c1a166222d9768de5954f811601fb7ca06fc8203905ce1" -dependencies = [ - "digest 0.11.3", - "keccak 0.2.0", -] - [[package]] name = "sha3" version = "0.12.0" @@ -4561,9 +4552,9 @@ dependencies = [ [[package]] name = "shlex" -version = "1.3.0" +version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" [[package]] name = "signal-hook" @@ -4671,9 +4662,9 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.3" +version = "0.6.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +checksum = "52d1cfed4120b4d927bf7c0f86d2087a4a7d6027c906d9f9d525a80573b9be51" dependencies = [ "libc", "windows-sys 0.61.2", @@ -4703,9 +4694,9 @@ checksum = "3a0219bd7d979d58245a4f41f695e1ac9f8befdffadd7f61f1bae9e39abc6620" [[package]] name = "sqlite-wasm-rs" -version = "0.5.4" +version = "0.5.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd578e94101503d97e2b286bbf8db2135035ca24b2ce4cbf3f9e2fb2bbf1eee" +checksum = "dc3efc0da82635d7e1ced0053bbbfa8c7ab9645d0bf36ceb4f7127bb85315d75" dependencies = [ "cc", "js-sys", @@ -5142,9 +5133,9 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.11+spec-1.1.0" +version = "0.25.12+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b59c4d22ed448339746c59b905d24568fcbb3ab65a500494f7b8c3e97739f2b" +checksum = "d2153edc6955a6c354fad8f5efd38b6a8769bdccf9fe50f8e1329f81b0baa5d7" dependencies = [ "indexmap 2.14.0", "toml_datetime 1.1.1+spec-1.1.0", @@ -5175,9 +5166,9 @@ checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tor-async-utils" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ee2d3e0d74d393dea1c73e5e3ca6a0f0c7920c21b4c72ae9820bc85ba86db2b" +checksum = "27fc83e7539ac5f717b722456df991ca8819782d04f7ae2ee28eefacca030eef" dependencies = [ "derive-deftly", "educe", @@ -5191,9 +5182,9 @@ dependencies = [ [[package]] name = "tor-basic-utils" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a20878eacdfbcd1fed2ee27092e66ccee2df871e0a6b92b6658efd59f739230" +checksum = "7bd01b9947f57a99193547f3a40a0bd179e9a332ead855c07e2fb702a1517f74" dependencies = [ "derive_more", "getrandom 0.4.2", @@ -5201,8 +5192,8 @@ dependencies = [ "itertools", "libc", "paste", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.1", + "rand_chacha 0.10.0", "serde", "slab", "smallvec", @@ -5213,9 +5204,9 @@ dependencies = [ [[package]] name = "tor-bytes" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "428748cf8cf3f2bd1a4cc30bab33512b1a7d3f8b1f2abd02e39cbcbbebc05aed" +checksum = "de6a7af55881d599cd48c841916520318d5711df17514001aafd5f796f61e337" dependencies = [ "bytes", "derive-deftly", @@ -5231,12 +5222,12 @@ dependencies = [ [[package]] name = "tor-cell" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9efd607ea595674678f73ebf77c245caf04e1d229eae8caf58d2989ff6c63959" +checksum = "dad292658d194b145aaec268ffb91566b7d96c98971eea4980c1bf951d7d082b" dependencies = [ "amplify", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "caret", "derive-deftly", @@ -5244,7 +5235,7 @@ dependencies = [ "educe", "itertools", "paste", - "rand 0.9.4", + "rand 0.10.1", "smallvec", "thiserror 2.0.18", "tor-basic-utils", @@ -5262,9 +5253,9 @@ dependencies = [ [[package]] name = "tor-cert" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6baf9c4f05d2c90e46fcb404039c2fe9628c2b56ef0101bb7bcb5f8e8125c8b1" +checksum = "474e732207b1be44f9ae4ddf295fba3644cd3c09494d131846ff9c9a624ab263" dependencies = [ "caret", "derive_builder_fork_arti", @@ -5280,9 +5271,9 @@ dependencies = [ [[package]] name = "tor-chanmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22ec7e9542c0100f974b0953f3c497755044d46a07fec5e23c1738fb5c5036c9" +checksum = "b2bb088804f54de320ee97dd2a48ae319f0df9d9d0e37eef050d65488194a768" dependencies = [ "async-trait", "base64ct", @@ -5296,7 +5287,7 @@ dependencies = [ "oneshot-fused-workaround", "percent-encoding", "postage", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "serde_with", @@ -5323,9 +5314,9 @@ dependencies = [ [[package]] name = "tor-checkable" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5b54e6c8c206b5fc1d49bfb000a19d3feb1b69650eb99bd51d68964882827527" +checksum = "9505791bac25b31b8de88c0e2b02e1690e10ff1308583c6f61c320bab2677959" dependencies = [ "humantime", "signature", @@ -5336,9 +5327,9 @@ dependencies = [ [[package]] name = "tor-circmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2402d71b92ff5221497713f7fdb124bd3861213382f7dee5cd93dd4c98ef5abf" +checksum = "7e986c7c4e1faae8a412b575ce66ff087050525a371db1034f0b24d70b459a2e" dependencies = [ "amplify", "async-trait", @@ -5355,7 +5346,7 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "pin-project", - "rand 0.9.4", + "rand 0.10.1", "retry-error", "safelog", "serde", @@ -5386,9 +5377,9 @@ dependencies = [ [[package]] name = "tor-config" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1b63f57706747de9221c97785c6dca52231af9c798cbcc49ac5c8bf1a70a69ea" +checksum = "224f85aa02b514c384ea5ec32f1b924233bea6b553195fd83da7f1e50a2e0d1b" dependencies = [ "amplify", "cfg-if", @@ -5420,9 +5411,9 @@ dependencies = [ [[package]] name = "tor-config-path" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a6010b61d49d2fa3f02957c237a8c70bf37f100e9408473965e67ce7d834e8d9" +checksum = "4232a56a8ce40a47ad6b42c9c7d9e6f4485e6e6a43b3149304c35bc295034107" dependencies = [ "directories", "serde", @@ -5434,9 +5425,9 @@ dependencies = [ [[package]] name = "tor-consdiff" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "499d69ee59f762ed7c4a68d87af2f1bc8a03735d43649217598b339e3031631f" +checksum = "d5fcd0c631a66b45dca4421b8a06309dd02ae5d6aea5daa55399f68638b2abf3" dependencies = [ "derive_more", "digest 0.10.7", @@ -5451,9 +5442,9 @@ dependencies = [ [[package]] name = "tor-dirclient" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "26d30d7a9a7b17ea5828dc171046f3a29b8e10b5c20a1ade257f744386f25aa4" +checksum = "bf637d6a5d8c886f23ca2df50edfaa56443f23294428f037206fb3a0d07ba57e" dependencies = [ "async-compression", "base64ct", @@ -5480,9 +5471,9 @@ dependencies = [ [[package]] name = "tor-dircommon" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4988c5af5d57fd6590ae7a50015366ebd54fbf2411247ff5a2346119493a078" +checksum = "35e412167d54c3618c3261dc77f1a31fe20c518f59aca5abfdec5f600b6a9b1f" dependencies = [ "base64ct", "derive-deftly", @@ -5501,9 +5492,9 @@ dependencies = [ [[package]] name = "tor-dirmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adeefa259f7d03ceb29bfbb631b786361fcba6234676d2f8ff71ae1820748f04" +checksum = "9b984d2b0f0a2764a1b24d7c4c203ef83600b071c0c5af36e20d4f63e80ce562" dependencies = [ "async-trait", "base64ct", @@ -5513,7 +5504,7 @@ dependencies = [ "educe", "event-listener", "fs-mistrust", - "fslock", + "fslock-guard", "futures", "hex", "humantime", @@ -5523,7 +5514,7 @@ dependencies = [ "oneshot-fused-workaround", "paste", "postage", - "rand 0.9.4", + "rand 0.10.1", "rusqlite", "safelog", "scopeguard", @@ -5558,9 +5549,9 @@ dependencies = [ [[package]] name = "tor-error" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd6728477fffe0d1ce98410635b1c690221e3d1d596e436aafa46d6a255d6466" +checksum = "1409b1a9085502c7797405a01fe3f3292a9827c9ed6a3f7c34fbb222572db5ac" dependencies = [ "derive_more", "futures", @@ -5576,9 +5567,9 @@ dependencies = [ [[package]] name = "tor-general-addr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "163dd5f727f4e98975dbb9f4ddc3e6129f0008985686334d5c5ff86b4733d112" +checksum = "09f17e219140e587e169f71f53e528ca394c39e83593c9fbe9e9366cce7655e1" dependencies = [ "derive_more", "thiserror 2.0.18", @@ -5587,9 +5578,9 @@ dependencies = [ [[package]] name = "tor-guardmgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f4cc40821c4b68f376a443ddd8530314efa1ebd38a1434b3ddb0c8555d2be972" +checksum = "d56c43d7afa526449c6e6460d373a02624a24c78637ddd7c68e2ac1879ae587c" dependencies = [ "amplify", "base64ct", @@ -5606,7 +5597,7 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "strum", @@ -5631,9 +5622,9 @@ dependencies = [ [[package]] name = "tor-hsclient" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc8df5e4ff97bc4086993c0943687f35f4565b0f767029c75ed46780a584f1eb" +checksum = "1ff8ba0afebc5f76b42240f76b2bfb92aa5862c19ff184492c59c654750ee275" dependencies = [ "async-trait", "derive-deftly", @@ -5644,7 +5635,7 @@ dependencies = [ "itertools", "oneshot-fused-workaround", "postage", - "rand 0.9.4", + "rand 0.10.1", "retry-error", "safelog", "slotmap-careful", @@ -5676,9 +5667,9 @@ dependencies = [ [[package]] name = "tor-hscrypto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "35f1db46a8bd6307f104dcc0a11f56a5b05f5823cc9d11e1748402daa940d3c7" +checksum = "3761c1828e74818cba4eb661d1eb79d4f663b05e8e7d3bc6e35d754968282fc0" dependencies = [ "cipher", "data-encoding", @@ -5689,7 +5680,7 @@ dependencies = [ "humantime", "itertools", "paste", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "signature", @@ -5709,9 +5700,9 @@ dependencies = [ [[package]] name = "tor-hsservice" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8aba207bceef6973f0607e92ee5606a001422c52a2c85ee95bf3964d8099c4ee" +checksum = "5fa87ed8d1ccfe8447b4e65bf0e2dfcab6a8ac9d2eadf7b3f37c102e1c18e9bb" dependencies = [ "amplify", "async-trait", @@ -5732,8 +5723,8 @@ dependencies = [ "once_cell", "oneshot-fused-workaround", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.1", + "rand_core 0.10.1", "retry-error", "safelog", "serde", @@ -5768,15 +5759,15 @@ dependencies = [ [[package]] name = "tor-key-forge" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0d9ae1ee46f439cfeb934bb1815ec460b94c1b6fce51b3b378d5253a1f99ce5b" +checksum = "86c877b95e0679f151743bc2b706320a29e14bd18d9bcb92918df34948e2e9d2" dependencies = [ "derive-deftly", "derive_more", "downcast-rs", "paste", - "rand 0.9.4", + "rand 0.10.1", "rsa", "signature", "ssh-key-fork-arti", @@ -5790,9 +5781,9 @@ dependencies = [ [[package]] name = "tor-keymgr" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0e968a3720ae845d1e4142d86b194cfa34c1e6f1c81eef29a5a285fc897f809" +checksum = "5f59fbec28e31d688822b4c00a6f7fd94539886baccd3ce1b35dc26699c37873" dependencies = [ "amplify", "arrayvec", @@ -5807,7 +5798,7 @@ dependencies = [ "humantime", "inventory", "itertools", - "rand 0.9.4", + "rand 0.10.1", "safelog", "serde", "signature", @@ -5831,9 +5822,9 @@ dependencies = [ [[package]] name = "tor-linkspec" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "048c5fe9e697c76f6326d73255ae9cc107a6b4f161b8b552f25f10157e31f85e" +checksum = "0e1cc8d5469c324d1e0761066d868363d3fdccb723b14cdc983a5aef9b67981b" dependencies = [ "base64ct", "by_address", @@ -5858,9 +5849,9 @@ dependencies = [ [[package]] name = "tor-llcrypto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01a40374cd6525a416d5131afeb7b2d9ea9ce838f32bd6175032b5a43a507c16" +checksum = "d0015d521f5e30a2f74879fe4c615fc2c3ace822d7cc88c1dfed0ae816b25f15" dependencies = [ "aes", "base64ct", @@ -5876,12 +5867,13 @@ dependencies = [ "getrandom 0.3.4", "getrandom 0.4.2", "hex", - "rand 0.9.4", - "rand_chacha 0.9.0", + "rand 0.10.1", + "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", @@ -5900,9 +5892,9 @@ dependencies = [ [[package]] name = "tor-log-ratelim" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c1ef1a9811152abad23de9783d21f72a1a89cd0655a8fae6f61e9b975679fce6" +checksum = "7d16152b21f2e7b309dfbdcbe587c5adba9547d3119f2933b453d6edbce7e8e0" dependencies = [ "futures", "humantime", @@ -5916,9 +5908,9 @@ dependencies = [ [[package]] name = "tor-memquota" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f170000c5fe0bef7f476b0b683a478e34b70f0ed6c2ad69d4b672aa93c101aa5" +checksum = "38ad2950d88e968572b0de6bb5702f5ed42f36555f362fb741c572c62441476e" dependencies = [ "cfg-if", "derive-deftly", @@ -5947,9 +5939,9 @@ dependencies = [ [[package]] name = "tor-memquota-cost" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "17475dcc5d5ddf008043c138a38ec70a8448c9509e099fa86be09ab92ee3601e" +checksum = "993ad5bfd39fb629990da40c2b2086284b1481b7d4843652a959db1d9e5ac463" dependencies = [ "derive-deftly", "itertools", @@ -5959,12 +5951,12 @@ dependencies = [ [[package]] name = "tor-netdir" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1351ffe2fff8bad7d5f2b79ec88ea188e159fe94cf705b1e485d8566526a7b5" +checksum = "6700c5e590818d9873a13e06b49b95cf3a39fa8fb3968bd67ec3295cd584b97c" dependencies = [ "async-trait", - "bitflags 2.11.1", + "bitflags 2.13.0", "derive_more", "digest 0.10.7", "futures", @@ -5972,7 +5964,7 @@ dependencies = [ "humantime", "itertools", "num_enum", - "rand 0.9.4", + "rand 0.10.1", "serde", "strum", "thiserror 2.0.18", @@ -5992,9 +5984,9 @@ dependencies = [ [[package]] name = "tor-netdoc" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1505b69e589e042276da708f7baa44d9f16ec50074a3f54ebb3bcbbcd6c4d9aa" +checksum = "a497ce4c36f59e4c67c352e3b80cd4642f6c5e660bb003de1922be4c7ed03081" dependencies = [ "amplify", "base64ct", @@ -6012,7 +6004,7 @@ dependencies = [ "memchr", "paste", "phf", - "rand 0.9.4", + "rand 0.10.1", "saturating-time", "serde", "serde_with", @@ -6041,16 +6033,15 @@ dependencies = [ [[package]] name = "tor-persist" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d574ac9a23c15d55d77a7c472294e762a4373bf0030e19d192eb523f1393ea60" +checksum = "b6da1a96a1f6418faac1cd1892e029fd265cf50e67e941a4ba2580d92dd32d47" dependencies = [ "amplify", "derive-deftly", "derive_more", "filetime", "fs-mistrust", - "fslock", "fslock-guard", "futures", "itertools", @@ -6071,9 +6062,9 @@ dependencies = [ [[package]] name = "tor-proto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5c97a19e322dab933540e50ab0b987e7f9946394b4e2884976ba4d371562e531" +checksum = "a0a5fa0e1a365fc089a7f140f4142379dd74c91c15103c2f9d11ea711e16029e" dependencies = [ "amplify", "async-trait", @@ -6099,8 +6090,8 @@ dependencies = [ "oneshot-fused-workaround", "pin-project", "postage", - "rand 0.9.4", - "rand_core 0.9.5", + "rand 0.10.1", + "rand_core 0.10.1", "safelog", "slotmap-careful", "smallvec", @@ -6138,9 +6129,9 @@ dependencies = [ [[package]] name = "tor-protover" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a2afba54106ccf364d8d422d7e33510e21e8004129574609ba26d2d8a2da1b81" +checksum = "c4f7f4ee15c9bfa0b700ae06e85fa52abddf17a35531cd3d3833412900e8da5c" dependencies = [ "caret", "paste", @@ -6152,11 +6143,11 @@ dependencies = [ [[package]] name = "tor-relay-selection" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ede9f32476abff4c61843bf3eba06ce03751fc62f472e0d9622185a5bfd1b009" +checksum = "5b565b6dec2e3edb7b033203a3662edb7b586e486d53e02af4295e05e7562870" dependencies = [ - "rand 0.9.4", + "rand 0.10.1", "serde", "tor-basic-utils", "tor-linkspec", @@ -6166,15 +6157,17 @@ dependencies = [ [[package]] name = "tor-rtcompat" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a4cbf776b2c52614b0ebaec7c2b93782bb5b61233f474ae46331976e38d8bbdc" +checksum = "fcd7a4ff4898044b3ec693c0bf098251accf13c58741c44ed9364ce5faa89287" dependencies = [ + "amplify", "async-trait", "async_executors", "asynchronous-codec", "cfg-if", "coarsetime", + "derive_builder_fork_arti", "derive_more", "dyn-clone", "educe", @@ -6201,9 +6194,9 @@ dependencies = [ [[package]] name = "tor-rtmock" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7a733ab3432e24f36b6f6680cc92413c61bb9a2041e09a17ac465cc3f2a788e5" +checksum = "8331e7d57bf7cf157cf3610ff6dbb5a591de5c3b22fa65ea6be18b08ff81477a" dependencies = [ "amplify", "assert_matches", @@ -6231,9 +6224,9 @@ dependencies = [ [[package]] name = "tor-socksproto" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b29d42752715cdf679ff7537f26457e2aabeabb682deec98beb79f01c8ee7bd" +checksum = "55f432eda94343bdadd73995f0b3082d96637dadb0e20c3f9a1bf2349f623d3c" dependencies = [ "amplify", "caret", @@ -6248,9 +6241,9 @@ dependencies = [ [[package]] name = "tor-units" -version = "0.42.0" +version = "0.43.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "527324d70bf67aadaa23fb6ebc585aae5ab7df1b8d16f9d361861d2c4c50dcee" +checksum = "8cf033ee8b22b11abd8272272e19ccbbf58c7b3152f8d224aa8d21f358c43d0d" dependencies = [ "derive-deftly", "derive_more", @@ -6282,7 +6275,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" dependencies = [ "async-compression", - "bitflags 2.11.1", + "bitflags 2.13.0", "bytes", "futures-core", "futures-util", @@ -6449,9 +6442,9 @@ checksum = "8e28f89b80c87b8fb0cf04ab448d5dd0dd0ade2f8891bae878de66a75a28600e" [[package]] name = "typenum" -version = "1.20.0" +version = "1.20.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" [[package]] name = "uncased" @@ -6476,9 +6469,9 @@ checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" [[package]] name = "unicode-segmentation" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" [[package]] name = "unicode-xid" @@ -6524,9 +6517,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.23.1" +version = "1.23.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +checksum = "d258b83ceec21034727ecee8c382cfa6c3e133699b0742c64571814fb420c9f7" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6705,7 +6698,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags 2.11.1", + "bitflags 2.13.0", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -6920,6 +6913,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" @@ -7158,7 +7160,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags 2.11.1", + "bitflags 2.13.0", "indexmap 2.14.0", "log", "serde", @@ -7289,9 +7291,9 @@ dependencies = [ [[package]] name = "yoke" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +checksum = "709fe23a0424b6a435d82152b1bd3fdfb0833487d5fa90d05d42762a9891fef5" dependencies = [ "stable_deref_trait", "yoke-derive", @@ -7312,18 +7314,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +checksum = "3b065d4f0e55f82fae73202e189638116a87c55ab6b8e6c2721e13dd9d854ad1" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.48" +version = "0.8.50" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +checksum = "0b631b19d36a892ab55420c92dbc83ccd79274f25be714855d3074aa71cab639" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index 5835eac5..fc7405b7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,9 +1,9 @@ [package] name = "rustchan" -version = "1.2.2" +version = "1.3.0" edition = "2021" -# axum 0.8 requires Rust 1.75; arti-client 0.41 bumps the effective floor to -# 1.90 — keep both fields in sync. +# axum 0.8 requires Rust 1.75; keep the package MSRV and cargo-msrv metadata +# in sync with the current dependency set. rust-version = "1.90" license = "MIT" description = "Self-contained imageboard — single binary, zero runtime dependencies" @@ -31,28 +31,28 @@ name = "chan" path = "src/lib.rs" [dependencies] -axum = { version = "0.8", features = ["multipart"] } -axum-extra = { version = "0.12", features = ["cookie"] } -tower = "0.5" -tower-http = { version = "0.6", features = ["fs", "set-header", "compression-full", "trace"] } -tokio = { version = "1", features = ["full"] } -tokio-util = "0.7" +axum = { version = "0.8.9", features = ["multipart"] } +axum-extra = { version = "0.12.6", features = ["cookie"] } +tower = "0.5.3" +tower-http = { version = "0.6.11", features = ["fs", "set-header", "compression-full", "trace"] } +tokio = { version = "1.52.3", features = ["full"] } +tokio-util = "0.7.18" captcha = { version = "1.0.0", default-features = false } rusqlite = { version = "0.39.0", features = ["bundled", "backup"] } -r2d2 = "0.8" +r2d2 = "0.8.10" r2d2_sqlite = "0.34.0" -time = "0.3" +time = "0.3.47" -serde = { version = "1", features = ["derive"] } -serde_json = "1" +serde = { version = "1.0.228", features = ["derive"] } +serde_json = "1.0.150" # toml 1.0: serde parsing is built-in, no feature flag needed. -toml = "1.0" +toml = "1.1.2" -argon2 = "0.5" -sha2 = "0.11" -subtle = "2" -hex = "0.4" +argon2 = "0.5.3" +sha2 = "0.11.0" +subtle = "2.6.1" +hex = "0.4.3" # rand_core 0.10 updates RustChan's direct RNG trait dependency, but the # password-hash stack still carries rand_core 0.6 transitively for salt # generation. Keep direct OS entropy reads on getrandom::SysRng and only use @@ -60,29 +60,29 @@ hex = "0.4" rand_core = "0.10.1" getrandom = { version = "0.4.2", features = ["sys_rng"] } -image = { version = "0.25", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "tiff", "ico"] } +image = { version = "0.25.10", default-features = false, features = ["jpeg", "png", "gif", "webp", "bmp", "tiff", "ico"] } # EXIF orientation correction: read Orientation tag from JPEG uploads and # apply the corresponding rotation before thumbnailing so that photos taken # on phones display upright (4.1). -kamadak-exif = "0.6" +kamadak-exif = "0.6.1" -clap = { version = "4", features = ["derive"] } +clap = { version = "4.6.1", features = ["derive"] } -uuid = { version = "1", features = ["v4", "serde"] } -chrono = { version = "0.4", features = ["serde"] } -dashmap = "6" -parking_lot = "0.12" -anyhow = "1" -once_cell = "1" -regex = "1" -ipnet = "2" +uuid = { version = "1.23.2", features = ["v4", "serde"] } +chrono = { version = "0.4.45", features = ["serde"] } +dashmap = "6.2.1" +parking_lot = "0.12.5" +anyhow = "1.0.102" +once_cell = "1.21.4" +regex = "1.12.3" +ipnet = "2.12.0" # zip 8: SimpleFileOptions and core ZipWriter/ZipArchive API unchanged. # by_index(), start_file(), add_directory(), finish() all stable. # Use file_names() iterator for name-only pre-flight scans (cleaner than # by_index_raw which is now by_index_without_decompression in zip 8). -zip = { version = "8", default-features = false, features = ["deflate"] } +zip = { version = "8.6.0", default-features = false, features = ["deflate"] } # ── HTTP client ──────────────────────────────────────────────────────────────── # reqwest for federation push (chan_refresh) and pull (chan_poll). @@ -91,19 +91,19 @@ zip = { version = "8", default-features = false, features = ["deflate"] } # rustls-no-provider keeps the build on the pure-Rust TLS stack and avoids # pulling in native-tls/OpenSSL. # Verify: cargo tree --edges features | grep -iE "openssl|native.tls" -reqwest = { version = "0.13", default-features = false, features = ["multipart", "json", "rustls-no-provider"] } +reqwest = { version = "0.13.4", default-features = false, features = ["multipart", "json", "rustls-no-provider"] } # Pin to patched version — fixes RUSTSEC-2026-0049 (CRL distribution point # matching bug). Transitive dep via reqwest / arti-client. -rustls-webpki = "0.103.10" +rustls-webpki = "0.103.13" -tracing = "0.1" -tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } -tracing-appender = "0.2" +tracing = "0.1.44" +tracing-subscriber = { version = "0.3.23", features = ["env-filter", "fmt", "json"] } +tracing-appender = "0.2.5" -thiserror = "2" -tempfile = "3" -libc = "0.2" +thiserror = "2.0.18" +tempfile = "3.27.0" +libc = "0.2.186" # ── Tor / onion-service stack ────────────────────────────────────────────────── # F-09: Use rustls throughout — native-tls would pull in OpenSSL as a @@ -119,41 +119,41 @@ libc = "0.2" # Post-change verification: # cargo tree --edges features | grep -iE "openssl|native.tls" # expect nothing # cargo tree -i openssl-sys # expect error: not found -arti-client = { version = "0.42.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } +arti-client = { version = "0.43.0", default-features = false, features = ["tokio", "rustls", "onion-service-service"] } # tor-hsservice / tor-cell: also part of the arti workspace. Their `full` # default feature bundle can activate the native-tls TLS backend through # tor-rtcompat. Disable defaults; add back only what the build requires. # If compilation fails with "feature X not found", re-add that feature here. -tor-hsservice = { version = "0.42.0", default-features = false } -tor-cell = { version = "0.42.0", default-features = false } +tor-hsservice = { version = "0.43.0", default-features = false } +tor-cell = { version = "0.43.0", default-features = false } -futures = "0.3" -sha3 = "0.11" -data-encoding = "2" +futures = "0.3.32" +sha3 = "0.12.0" +data-encoding = "2.11.0" # ── TLS ─────────────────────────────────────────────────────────────────────── # rustls-pki-types: standalone PKI types crate split out of rustls 0.23+. # Required directly by src/tls/mod.rs (CertificateDer, PrivateKeyDer, PemObject). -rustls-pki-types = "1.9" -tokio-rustls = "0.26" -rustls = { version = "0.23", features = ["ring"] } +rustls-pki-types = "1.14.1" +tokio-rustls = "0.26.4" +rustls = { version = "0.23.40", features = ["ring"] } # Optional — gated by Cargo features -rustls-acme = { version = "0.15.1", features = ["tokio"], optional = true } -rcgen = { version = "0.14", optional = true } -pem-rfc7468 = { version = "1.0", optional = true, features = ["std"] } -x509-cert = { version = "0.2", optional = true } +rustls-acme = { version = "0.15.3", features = ["tokio"], optional = true } +rcgen = { version = "0.14.8", optional = true } +pem-rfc7468 = { version = "1.0.0", optional = true, features = ["std"] } +x509-cert = { version = "0.2.5", optional = true } # HTTPS listen integration for Axum (handles Static cert path) -axum-server = { version = "0.8", features = ["tls-rustls-no-provider"] } -hyper = { version = "1", features = ["http1", "http2", "server"] } -hyper-util = { version = "0.1", features = ["tokio", "server"] } +axum-server = { version = "0.8.0", features = ["tls-rustls-no-provider"] } +hyper = { version = "1.10.1", features = ["http1", "http2", "server"] } +hyper-util = { version = "0.1.20", features = ["tokio", "server"] } # ── Terminal UI ─────────────────────────────────────────────────────────────── # crossterm: required by src/server/console/ (wizard.rs, input.rs, mod.rs). # Provides cross-platform terminal control (cursor, events, raw mode). -crossterm = "0.29" +crossterm = "0.29.0" [features] default = ["tls-self-signed"] diff --git a/README.md b/README.md index 689521b3..d30e7d8d 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ One binary. One data folder. SQLite only. The rest is features. RustChan is built in Rust, ships with bundled SQLite, and is designed to be understandable, movable, and fun to run. -Current development version: `1.2.2`. +Current development version: `1.3.0`. [What is RustChan?](#what-is-rustchan) · [Why it exists](#why-it-exists) · @@ -163,6 +163,12 @@ rustchan-data/ That folder is the thing to back up if you want to move the site or keep it safe. +For RustChan `1.3.0`, fresh databases are created directly from the `1.3.0` +baseline schema. Earlier internal development migrations were squashed before +release; a database that already matches the baseline is marked as schema +version `1.3.0`, while partial or unknown schemas fail closed without deleting +data. + `settings.toml` is generated automatically on first run and documents the available options inline. A few of the more important ones: ```toml @@ -214,9 +220,23 @@ CLI admin commands include: - `rustchan-cli admin ban` - `rustchan-cli admin unban` - `rustchan-cli admin list-bans` +- `rustchan-cli admin db-status` Run `rustchan-cli admin --help` for the full command list and flags. +## Observability Endpoints + +`/healthz` is intentionally public and minimal for liveness checks. `/readyz` returns only readiness status by default, and `/metrics` returns `404` unless explicitly enabled. + +If a trusted Prometheus scraper or private load balancer needs detailed internals, enable the public surface in `settings.toml` and restrict access at your reverse proxy or network boundary: + +```toml +public_readiness_details = true +public_metrics_enabled = true +``` + +The detailed readiness and metrics output includes operational state such as database schema health, backup freshness, media backlog, maintenance state, and Tor readiness. Do not expose those endpoints directly to the public internet or onion visitors. + ## Tor And ChanNet RustChan includes built-in Tor onion service support via Arti. You do not need to install or manage a separate `tor` daemon. diff --git a/SETUP.md b/SETUP.md index f9646124..27f5c266 100644 --- a/SETUP.md +++ b/SETUP.md @@ -2,7 +2,7 @@ Current setup and deployment guide for Linux, macOS, and Windows. -Current development version: `1.2.2`. +Current development version: `1.3.0`. This guide reflects the current RustChan architecture: @@ -265,7 +265,7 @@ rustchan-data/ ## Banner Artwork Requirements -RustChan `1.2.2` includes board banners plus a separate home-page announcement banner. +RustChan `1.3.0` includes board banners plus a separate home-page announcement banner. Banner upload requirements: @@ -413,6 +413,19 @@ This means: - HTTPS support is configured but disabled until you turn it on - when enabled, RustChan can generate a local self-signed development certificate +## Observability Endpoints + +`/healthz` is public and intentionally minimal. `/readyz` returns only a readiness status by default, and `/metrics` returns `404` unless explicitly enabled. + +Only enable detailed readiness or metrics for a trusted scrape path: + +```toml +public_readiness_details = true +public_metrics_enabled = true +``` + +Detailed readiness and metrics include operational state such as database schema health, backup freshness, media backlog, maintenance state, and Tor readiness. If you expose them, use a reverse-proxy allowlist, private network, or equivalent network boundary. Tor-facing deployments should keep the defaults unless you intentionally monitor those endpoints externally. + ## Default Settings | # | Setting | Scope | Default | Enabled by default? | Admin? | Config? | Notes | @@ -442,6 +455,8 @@ This means: | 51 | ACME staging | TLS | `true` | true | No | Yes | Applies if the ACME section is enabled and the field is omitted. | | 53 | Built-in Tor support | Tor | `true` | true | No | Yes | Generated config enables Tor support by default. | | 54 | Tor-only mode | Tor | `false` | false | No | Yes | Keeps clearnet access on unless you explicitly disable it. | +| 55 | Public detailed readiness | observability | `false` | false | No | Yes | Keep off unless `/readyz` is behind a trusted scrape path. | +| 56 | Public metrics | observability | `false` | false | No | Yes | Keep off unless `/metrics` is behind a trusted scrape path. | | 60 | Include Tor hidden-service keys in automatic full backups | backup / Tor | `true` | true | Yes | Yes | Admin saves rewrite `settings.toml`; existing installs keep their current configured value until changed. | | 66 | Archive before prune | maintenance / archive | `true` | true | No | Yes | Global override: prune archives instead of hard-deletes. | | 72 | ChanNet API key set | ChanNet | `""` | false | No | Yes | Empty disables the protected ChanNet endpoints. | @@ -599,6 +614,13 @@ Before major updates, back up: Or use the built-in backup tools from the admin panel. +RustChan `1.3.0` resets the database baseline: fresh installs create the +current `1.3.0` schema directly instead of replaying pre-release internal +migrations. Existing in-development databases that structurally match that +schema are marked as database schema version `1.3.0`; partial, unknown, or +corrupt schemas are rejected without deleting data. Future released schema +changes should add normal forward migrations tied to RustChan release versions. + ## Troubleshooting ### The TUI shows ffmpeg warnings diff --git a/docs/channet_static_security_audit.txt b/docs/channet_static_security_audit.txt new file mode 100644 index 00000000..95e78e1d --- /dev/null +++ b/docs/channet_static_security_audit.txt @@ -0,0 +1,355 @@ +ChanNet Static Security Audit +============================= + +Date: 2026-06-07 +Repository: RustChan +Scope: Static-only audit of ChanNet. This record was created after the audit +and is intended for future reference. + +Audit Scope +----------- + +Included: +- src/chan_net/** +- src/db/chan_net.rs +- Router/server wiring that exposes ChanNet routes +- Config fields, docs, tests, and handlers directly related to ChanNet +- Shared utilities only where ChanNet calls them + +Threat model: +- Anonymous remote caller hitting ChanNet routes +- Malicious adjacent-project caller +- High-volume/spam caller +- Malformed JSON/form input +- Path/query/body abuse +- Database state corruption attempts +- Replay/duplicate message attempts +- Privacy/info-leak attempts +- Cross-board/thread/post reference abuse +- Deployment where ChanNet is exposed on clearnet or onion + +Important Context +----------------- + +ChanNet is intentionally designed with unauthenticated adjacent-project routes +for future RustWave use. This audit does not classify unauthenticated ChanNet +route existence as a vulnerability by itself. + +The optional second listener is off unless the server is started with +--chan-net. By default it binds to 127.0.0.1:7070. The /chan/refresh and +/chan/poll routes require X-ChanNet-Key when a key is configured. + +Final Verdict +------------- + +STATIC FAIL + +The static audit found security-relevant issues beyond the intentional +unauthenticated adjacent-project design. The highest-impact findings are +protected-board export bypass and locked-thread reply bypass through +/chan/command reply_push. + +ChanNet Route Inventory +----------------------- + +GET /chan/status +- Inputs: none +- Response: JSON object with service, chan_net, version, boards, posts +- Main effect: DB counts only +- Code path: src/chan_net/status.rs::chan_status + +POST /chan/export +- Inputs: none +- Response: application/zip +- Main effect: builds a full active-board/post snapshot +- Code path: src/chan_net/export.rs::chan_export -> snapshot::build_snapshot + +POST /chan/import +- Inputs: raw ZIP body, capped by CONFIG.chan_net_max_body +- Response: JSON {"imported": N} +- Main effect: writes boards, chan_net_posts, and import ledger rows +- Code path: src/chan_net/import.rs::chan_import -> do_import + +POST /chan/refresh +- Inputs: X-ChanNet-Key +- Response: JSON {"status", "local_tx_id", "broadcast_tx_id"} +- Main effect: builds a local snapshot and POSTs it to rustwave_url +- Code path: src/chan_net/refresh.rs::chan_refresh + +POST /chan/poll +- Inputs: X-ChanNet-Key +- Response: JSON {"imported": N} +- Main effect: pulls up to 50 remote ZIP snapshots and imports them +- Code path: src/chan_net/poll.rs::chan_poll -> import::do_import + +POST /chan/command +- Inputs: application/json command body, capped by + CONFIG.chan_net_command_max_body +- Response: ZIP with Content-Disposition +- Main effect: export commands return scoped ZIPs; reply_push inserts a live + post into posts +- Code path: src/chan_net/command.rs::chan_command + +Intentional Design Choices +-------------------------- + +Not reported as standalone vulnerabilities: +- ChanNet has unauthenticated routes by design for adjacent-project use. +- ChanNet is a separate listener and is off unless --chan-net is used. +- Text-only snapshots and gateway replies omit media paths, MIME types, + thumbnails, and binary data by design. + +Positive Observations +--------------------- + +- No SQL injection was found in the ChanNet paths inspected. Inputs are + parameterized. The dynamic SQL in selective_snapshot::fetch_posts only + appends fixed fragments. +- No request-controlled SSRF was found. rustwave_url is configuration-derived + and startup validation requires http:// or https://. +- No filesystem extraction or ZIP path traversal was found in snapshot import. + ZIP filenames are whitelisted and entries are parsed in memory. +- No ChanNet-specific stored XSS was found in reply_push. The live reply path + escapes content with escape_html and stores rendered body_html through the + existing post-body pipeline. + +Findings +-------- + +HIGH: Protected board data is exported without board-access policy + +Exploit scenario: +A caller that can reach ChanNet can export text from view_password or +post_password boards using /chan/export or /chan/command variants such as +full_export, board_export, thread_export, archive_export, and force_refresh. +This bypasses the main web board access gate. + +Affected files/functions/routes: +- src/chan_net/snapshot.rs::build_snapshot +- src/chan_net/selective_snapshot.rs::build_full_snapshot +- src/chan_net/selective_snapshot.rs::build_board_snapshot +- src/chan_net/selective_snapshot.rs::build_thread_snapshot +- src/chan_net/selective_snapshot.rs::build_archive_snapshot +- src/chan_net/selective_snapshot.rs::build_force_refresh_snapshot +- POST /chan/export +- POST /chan/command + +Evidence from code: +- Normal web routes call board_access_preflight in src/handlers/board.rs. +- ChanNet snapshot queries select boards/posts without filtering + boards.access_mode. +- Board access modes include public, view_password, and post_password in + src/models.rs. + +Suggested fix: +Define an explicit ChanNet sharing policy. Default snapshot exports to public +or explicitly ChanNet-enabled boards only, and apply the same policy in every +snapshot builder. + +HIGH: reply_push can post into locked threads + +Exploit scenario: +A malicious adjacent caller can post live replies into a moderator-locked +thread via POST /chan/command with type=reply_push. + +Affected files/functions/routes: +- src/db/chan_net.rs::insert_reply_into_thread +- src/chan_net/command.rs::chan_command +- POST /chan/command reply_push + +Evidence from code: +- Normal posting rejects locked threads in src/handlers/posting.rs when + thread.locked is true. +- ChanNet reply insertion checks thread id, board short name, and + t.archived = 0, but does not check t.locked = 0. +- insert_reply_into_thread then calls create_reply_with_thread_update, which + inserts the post and increments reply_count/bumped_at. + +Suggested fix: +Add locked-thread rejection to the ChanNet precondition, for example by adding +AND t.locked = 0 to the lookup and returning the same class of error as normal +posting. + +MEDIUM: reply_push has no replay/idempotency protection + +Exploit scenario: +Replaying the same JSON creates duplicate live replies. The timestamp field is +ignored for storage, and there is no remote message id, submission token, or +unique key tied to the adjacent caller's message. + +Affected files/functions/routes: +- src/chan_net/command.rs::Command::ReplyPush +- src/db/chan_net.rs::insert_reply_into_thread +- src/db/posts.rs::create_post_inner +- POST /chan/command reply_push + +Evidence from code: +- ReplyPush fields are board, thread_id, author, content, timestamp. +- insert_reply_into_thread ignores timestamp. +- create_post_inner performs a plain INSERT INTO posts ... RETURNING id. + +Suggested fix: +Require a stable remote message id or tx id for reply_push, store it durably +with a unique constraint, and reject duplicates before inserting a live post. + +MEDIUM: ZIP import can decompress/deserialize beyond the request body cap + +Exploit scenario: +A small ZIP under chan_net_max_body can contain very large uncompressed JSON +entries. unpack_snapshot reads each JSON file to the end and deserializes the +whole array, allowing memory and CPU exhaustion. + +Affected files/functions/routes: +- src/chan_net/snapshot.rs::unpack_snapshot +- src/chan_net/import.rs::do_import +- POST /chan/import +- POST /chan/poll + +Evidence from code: +- /chan/import applies DefaultBodyLimit::max(CONFIG.chan_net_max_body). +- unpack_snapshot uses f.read_to_end into Vec for boards.json, posts.json, and + metadata.json. +- There is no uncompressed entry size cap, maximum board count, maximum post + count, or streaming parser bound. + +Suggested fix: +Enforce per-entry uncompressed limits, maximum boards/posts, and total decoded +object limits before or during JSON parsing. + +MEDIUM: Import dedupe and DB writes are not atomic + +Exploit scenario: +Concurrent imports with the same tx_id can pass the in-memory ledger check +before either records the durable ledger row. Both can then write content. A +mid-import DB error can also leave partial boards/posts without recording the +tx_id, allowing retries to operate on partially applied state. + +Affected files/functions/routes: +- src/chan_net/import.rs::do_import +- src/db/chan_net.rs::record_import_tx_id +- POST /chan/import +- POST /chan/poll + +Evidence from code: +- do_import checks the in-memory ledger before DB writes. +- The blocking import loop inserts boards and posts, then records the tx_id. +- The writes are not wrapped in an explicit DB transaction. +- record_import_tx_id uses INSERT OR IGNORE, so duplicate durable claims are + not treated as an import-stopping condition. + +Suggested fix: +Wrap import in a transaction. Claim the tx_id with a unique insert before +content writes and roll back the claim/content together on failure. + +MEDIUM: Snapshot import accepts invalid board/post metadata + +Exploit scenario: +A remote snapshot can create boards with short names that normal setup rejects, +including overlong or path-hostile values. It can also provide unbounded +authors/titles and out-of-range remote ids/timestamps, resulting in corrupted +or hard-to-manage DB state. + +Affected files/functions/routes: +- src/chan_net/import.rs::do_import +- src/db/chan_net.rs::insert_board_if_absent +- src/db/chan_net.rs::insert_post_if_absent +- POST /chan/import +- POST /chan/poll + +Evidence from code: +- Normal setup validates board slugs as 1-8 lowercase ASCII letters/digits. +- ChanNet import only checks that post.board is non-empty and content length is + <= 32,768. +- insert_board_if_absent inserts incoming board.id directly as boards.short_name. +- SnapshotPost uses u64 post_id and timestamp, which are cast to signed + integers before DB insertion. + +Suggested fix: +Validate imported board ids against the local slug policy or a separate strict +remote-id type. Bound title and author lengths. Reject posts referencing boards +not present in the snapshot. Validate remote ids/timestamps before casting. + +LOW: Heavy ChanNet routes lack rate/concurrency limits + +Exploit scenario: +Repeated /chan/export, force_refresh, import, or poll calls can saturate DB +connections, blocking threads, CPU, and memory through in-memory ZIP builds and +large JSON processing. + +Affected files/functions/routes: +- src/chan_net/mod.rs::chan_router +- src/chan_net/export.rs::chan_export +- src/chan_net/selective_snapshot.rs::build_force_refresh_snapshot +- POST /chan/export +- POST /chan/import +- POST /chan/command force_refresh +- POST /chan/poll + +Evidence from code: +- src/chan_net/mod.rs explicitly excludes rate-limit middleware for ChanNet. +- Main server router applies rate_limit_middleware separately to the main web + router. +- ChanNet export and command builders build ZIPs in memory. + +Suggested fix: +Add ChanNet-specific rate or concurrency gates for heavyweight routes. If +operators expose ChanNet beyond loopback, require the existing API key or a +separate capability token for resource-heavy operations. + +INFORMATIONAL: /chan/status leaks version and aggregate activity + +Exploit scenario: +Reachable callers can observe RustChan version and board/post counts, which can +aid fingerprinting and activity monitoring. + +Affected files/functions/routes: +- src/chan_net/status.rs::chan_status +- GET /chan/status + +Evidence from code: +- The JSON response includes env!("CARGO_PKG_VERSION"), boards count, and posts + count. + +Suggested fix: +Consider a minimal default health response, with detailed status available only +when explicitly enabled. + +Coverage Gaps +------------- + +Existing ChanNet-specific coverage is thin. The audit found a focused DB test +for gateway reply escaping and NULL ip_hash preservation, plus broader +sanitizer/config tests, but not enough route-level ChanNet coverage. + +Recommended future tests: +- Protected-board export policy for /chan/export and every /chan/command export + variant. +- Locked-thread reply_push rejection. +- Duplicate/replayed reply_push rejection. +- Concurrent same-tx_id imports. +- ZIP bomb or large uncompressed entry rejection. +- Invalid imported board/post metadata rejection. +- /chan/refresh and /chan/poll API-key behavior. +- JSON body-limit error shape for /chan/command. +- Full/force export resource-limit behavior. +- Import rollback behavior when one board/post insert fails. + +Commands Used During Audit +-------------------------- + +Only read-only inspection commands were used: +- pwd +- git status --short +- rg --files +- targeted rg -n searches +- find for ChanNet-related files +- nl -ba ... | sed -n ... for source inspection +- unzip -p docs/channet_api_reference.docx word/document.xml | tr '<' '\n' | rg -n ... + +No cargo fmt, clippy, tests, Playwright, migration generation, commits, or +automated fixers were run during the static audit. + +Files Changed By This Follow-Up +------------------------------- + +- docs/channet_static_security_audit.txt diff --git a/src/config.rs b/src/config.rs index b1e1b95d..4b877900 100644 --- a/src/config.rs +++ b/src/config.rs @@ -1,4 +1,5 @@ // Runtime configuration and settings-file loading. +use anyhow::Context as _; use serde::Deserialize; use std::env; use std::io::Write as _; @@ -8,6 +9,9 @@ use std::sync::LazyLock; mod template; +#[cfg(test)] +pub static RUNTIME_LAYOUT_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + /// Absolute path to the directory the running binary lives in. fn binary_dir() -> PathBuf { std::env::current_exe() @@ -210,6 +214,13 @@ struct SettingsFile { max_video_size_mb: Option, max_audio_size_mb: Option, cookie_secret: Option, + behind_proxy: Option, + https_cookies: Option, + /// Expose detailed `/readyz` internals such as schema, backup, media queue, + /// maintenance, and Tor readiness to unauthenticated clients. Default: false. + public_readiness_details: Option, + /// Expose unauthenticated Prometheus metrics at `/metrics`. Default: false. + public_metrics_enabled: Option, enable_tor_support: Option, /// When true, the HTTP server binds exclusively to 127.0.0.1 so it is /// reachable only through the Tor hidden service. Overrides the host @@ -318,6 +329,10 @@ fn load_settings_file() -> SettingsFile { parse_settings_file_str(&raw).unwrap_or_else(|_| settings_file_parse_error(&path)) } +#[expect( + clippy::exit, + reason = "invalid configuration must terminate with the standard EX_CONFIG status" +)] fn settings_file_parse_error(path: &Path) -> ! { let _ = writeln!( std::io::stderr().lock(), @@ -556,6 +571,10 @@ pub struct Config { /// Trusted proxy CIDR allowlist for forwarding headers. pub trusted_proxy_cidrs: Vec, pub https_cookies: bool, + /// When true, `/readyz` includes detailed operational internals for public clients. + pub public_readiness_details: bool, + /// When true, `/metrics` exposes Prometheus metrics to unauthenticated clients. + pub public_metrics_enabled: bool, /// Public hostnames accepted by the HTTP→HTTPS redirect listener. pub public_hosts: Vec, /// Interval in seconds between WAL checkpoint runs. 0 = disabled. @@ -694,7 +713,7 @@ impl Config { } else { bind_addr }; - let behind_proxy = env_bool("CHAN_BEHIND_PROXY", false); + let behind_proxy = env_bool("CHAN_BEHIND_PROXY", s.behind_proxy.unwrap_or(false)); let https_cookies_default = behind_proxy || tls.enabled; let trusted_proxy_cidrs = env_list( "CHAN_TRUSTED_PROXY_CIDRS", @@ -802,7 +821,18 @@ impl Config { session_duration: env_parse("CHAN_SESSION_SECS", 8 * 3600), behind_proxy, trusted_proxy_cidrs, - https_cookies: env_bool("CHAN_HTTPS_COOKIES", https_cookies_default), + https_cookies: env_bool( + "CHAN_HTTPS_COOKIES", + s.https_cookies.unwrap_or(https_cookies_default), + ), + public_readiness_details: env_bool( + "CHAN_PUBLIC_READINESS_DETAILS", + s.public_readiness_details.unwrap_or(false), + ), + public_metrics_enabled: env_bool( + "CHAN_PUBLIC_METRICS_ENABLED", + s.public_metrics_enabled.unwrap_or(false), + ), public_hosts, wal_checkpoint_interval: env_parse( "CHAN_WAL_CHECKPOINT_SECS", @@ -1190,20 +1220,15 @@ fn rewrite_settings_file_lines( out } -fn update_settings_file_entries(updates: &[(&str, String)], insert_missing_before: Option<&str>) { +fn update_settings_file_entries_result( + updates: &[(&str, String)], + insert_missing_before: Option<&str>, +) -> anyhow::Result<()> { // Escape backslash and double-quote, then wrap in double quotes. let path = settings_file_path(); let content = match std::fs::read_to_string(&path) { - Ok(c) => c, - Err(e) => { - tracing::warn!( - target: "config", - path = %path.display(), - error = %e, - "Could not read settings.toml for update" - ); - return; - } + Ok(content) => content, + Err(error) => anyhow::bail!("could not read {}: {error}", path.display()), }; // Replace the value portion of `key = ...` lines while preserving file // order and unrelated comments. @@ -1212,40 +1237,27 @@ fn update_settings_file_entries(updates: &[(&str, String)], insert_missing_befor // over the target. This prevents a partial write from corrupting settings.toml // if the process is killed mid-write (rename(2) is atomic on POSIX). let dir = path.parent().unwrap_or_else(|| std::path::Path::new(".")); - match tempfile::Builder::new() + let mut tmp = tempfile::Builder::new() .prefix(".settings_") .suffix(".tmp") .tempfile_in(dir) - { - Err(e) => { - tracing::warn!( - target: "config", - path = %path.display(), - error = %e, - "Could not create temp file for settings.toml update" - ); - } - Ok(mut tmp) => { - use std::io::Write as _; - let write_result = tmp - .write_all(out.as_bytes()) - .and_then(|()| tmp.as_file().sync_all()); - if let Err(e) = write_result { - tracing::warn!( - target: "config", - path = %path.display(), - error = %e, - "Could not write settings.toml temp file" - ); - } else if let Err(e) = tmp.persist(&path) { - tracing::warn!( - target: "config", - path = %path.display(), - error = %e.error, - "Could not atomically replace settings.toml" - ); - } - } + .with_context(|| format!("could not create temp file for {}", path.display()))?; + tmp.write_all(out.as_bytes()) + .and_then(|()| tmp.as_file().sync_all()) + .with_context(|| format!("could not write temp settings file for {}", path.display()))?; + tmp.persist(&path) + .map_err(|error| error.error) + .with_context(|| format!("could not atomically replace {}", path.display()))?; + Ok(()) +} + +fn update_settings_file_entries(updates: &[(&str, String)], insert_missing_before: Option<&str>) { + if let Err(error) = update_settings_file_entries_result(updates, insert_missing_before) { + tracing::warn!( + target: "config", + error = %error, + "Could not update settings.toml" + ); } } @@ -1310,6 +1322,108 @@ pub fn update_settings_file_auto_full_backup( ); } +#[derive(Clone, Copy)] +#[expect( + clippy::struct_excessive_bools, + reason = "settings update mirrors independent runtime setup toggles" +)] +pub struct SetupRuntimeSettingsUpdate { + pub enable_tor_support: bool, + pub tor_only: bool, + pub behind_proxy: bool, + pub https_cookies: bool, + pub max_image_size_mb: u64, + pub max_video_size_mb: u64, + pub max_audio_size_mb: u64, +} + +#[expect( + clippy::struct_excessive_bools, + reason = "settings update mirrors independent first-run setup toggles" +)] +pub struct SetupSettingsFileUpdate<'a> { + pub forum_name: &'a str, + pub site_subtitle: &'a str, + pub homepage_new_thread_badges_enabled: bool, + pub homepage_new_reply_badges_enabled: bool, + pub thread_new_reply_badges_enabled: bool, + pub default_theme: &'a str, + pub auto_full_backup_interval_hours: u64, + pub auto_full_backup_copies_to_keep: u64, + pub auto_full_backup_include_tor_hidden_service_keys: bool, + pub auto_full_backup_storage_mode: &'a str, + pub auto_full_backup_split_zip_part_size_gib: u64, + pub runtime: SetupRuntimeSettingsUpdate, +} + +/// Persist all setup-owned runtime settings in one atomic settings.toml rewrite. +/// +/// # Errors +/// Returns an error if the settings file cannot be read or atomically replaced. +pub fn update_settings_file_setup(update: &SetupSettingsFileUpdate<'_>) -> anyhow::Result<()> { + update_settings_file_entries_result( + &[ + ("forum_name", toml_quote(update.forum_name)), + ("site_subtitle", toml_quote(update.site_subtitle)), + ( + "homepage_new_thread_badges_enabled", + update.homepage_new_thread_badges_enabled.to_string(), + ), + ( + "homepage_new_reply_badges_enabled", + update.homepage_new_reply_badges_enabled.to_string(), + ), + ( + "thread_new_reply_badges_enabled", + update.thread_new_reply_badges_enabled.to_string(), + ), + ("default_theme", toml_quote(update.default_theme)), + ( + "enable_tor_support", + update.runtime.enable_tor_support.to_string(), + ), + ("tor_only", update.runtime.tor_only.to_string()), + ("behind_proxy", update.runtime.behind_proxy.to_string()), + ("https_cookies", update.runtime.https_cookies.to_string()), + ( + "max_image_size_mb", + update.runtime.max_image_size_mb.to_string(), + ), + ( + "max_video_size_mb", + update.runtime.max_video_size_mb.to_string(), + ), + ( + "max_audio_size_mb", + update.runtime.max_audio_size_mb.to_string(), + ), + ( + "auto_full_backup_interval_hours", + update.auto_full_backup_interval_hours.to_string(), + ), + ( + "auto_full_backup_copies_to_keep", + update.auto_full_backup_copies_to_keep.max(1).to_string(), + ), + ( + "auto_full_backup_include_tor_hidden_service_keys", + update + .auto_full_backup_include_tor_hidden_service_keys + .to_string(), + ), + ( + "auto_full_backup_storage_mode", + toml_quote(update.auto_full_backup_storage_mode), + ), + ( + "auto_full_backup_split_zip_part_size_gib", + update.auto_full_backup_split_zip_part_size_gib.to_string(), + ), + ], + Some("# Optional explicit ffmpeg binary path."), + ) +} + pub fn update_settings_file_ffmpeg_timeout(timeout_secs: u64) { let timeout_secs = timeout_secs.clamp(MIN_FFMPEG_TIMEOUT_SECS, MAX_FFMPEG_TIMEOUT_SECS); update_settings_file_entries( @@ -1520,6 +1634,8 @@ mod tests { behind_proxy: false, trusted_proxy_cidrs: vec!["127.0.0.1/32".to_owned(), "::1/128".to_owned()], https_cookies: false, + public_readiness_details: false, + public_metrics_enabled: false, public_hosts: Vec::new(), wal_checkpoint_interval: 3600, auto_vacuum_interval_hours: 24, @@ -1914,6 +2030,8 @@ port = 8080 assert!(template.contains("auto_full_backup_include_tor_hidden_service_keys = true")); assert!(template.contains(r#"auto_full_backup_storage_mode = "directory""#)); assert!(template.contains("auto_full_backup_split_zip_part_size_gib = 4")); + assert!(template.contains("public_readiness_details = false")); + assert!(template.contains("public_metrics_enabled = false")); assert!(template.contains( r#"enabled_builtin_themes = ["forest", "blue-sky", "deep-orbit", "terminal", "dorfic", "chanclassic", "aero", "neoncubicle", "fluorogrid"]"# )); @@ -1946,14 +2064,28 @@ port = 8080 let parsed = super::parse_settings_file_str(&template).expect("parse generated template"); assert_eq!(parsed.cookie_secret.as_deref(), Some(secret.as_str())); assert_eq!(parsed.enable_tor_support, Some(true)); + assert_eq!(parsed.tor_only, Some(false)); + assert_eq!(parsed.public_readiness_details, Some(false)); + assert_eq!(parsed.public_metrics_enabled, Some(false)); + assert!(parsed.public_hosts.is_none()); assert_eq!(parsed.tls.as_ref().map(|tls| tls.enabled), Some(false)); assert_eq!(parsed.tls.as_ref().map(|tls| tls.port), Some(8443)); + assert_eq!( + parsed.tls.as_ref().map(|tls| tls.redirect_http), + Some(false) + ); + assert!(template.contains("runtime/tor/state/keystore/")); let reloaded = Config::from_env(); assert_eq!(reloaded.cookie_secret, secret); assert!(reloaded.enable_tor_support); + assert!(!reloaded.tor_only); + assert!(!reloaded.public_readiness_details); + assert!(!reloaded.public_metrics_enabled); + assert!(reloaded.public_hosts.is_empty()); assert!(!reloaded.tls.enabled); assert_eq!(reloaded.tls.port, 8443); + assert!(!reloaded.tls.redirect_http); match previous { Some(contents) => std::fs::write(&path, contents).expect("restore settings file"), diff --git a/src/config/template.rs b/src/config/template.rs index 4fc137a0..4cf958aa 100644 --- a/src/config/template.rs +++ b/src/config/template.rs @@ -48,14 +48,20 @@ trusted_proxy_cidrs = ["127.0.0.1/32", "::1/128"] # Usually only needed when binding to 0.0.0.0/:: with a manual certificate. # public_hosts = ["example.com", "www.example.com"] +# Public observability defaults are intentionally quiet for internet and onion exposure. +# /healthz remains public and minimal. Set these only for a trusted scrape path, +# typically behind a reverse-proxy allowlist or private network. +public_readiness_details = false +public_metrics_enabled = false + # ── Upload limits ───────────────────────────────────────────────────────────── # Maximum size for image uploads in MiB (jpg, png, gif, webp, heic). max_image_size_mb = 8 -# Maximum size for video uploads in MiB (mp4, webm). +# Maximum size for video uploads in MiB (mp4, webm, mkv). max_video_size_mb = 50 -# Maximum size for audio uploads in MiB (mp3, ogg, flac, wav, m4a, aac). +# Maximum size for audio uploads in MiB (mp3, ogg/oga, opus, flac, wav, m4a, aac, webm audio). max_audio_size_mb = 150 # Master switch for arbitrary file uploads. diff --git a/src/db/admin.rs b/src/db/admin.rs index 4c39a507..6cca004d 100644 --- a/src/db/admin.rs +++ b/src/db/admin.rs @@ -39,6 +39,7 @@ impl DbCheckResult { #[derive(Debug, Clone)] pub struct DbHealthSnapshot { + pub schema: DbCheckResult, pub integrity: DbCheckResult, pub foreign_keys: DbCheckResult, } @@ -46,7 +47,7 @@ pub struct DbHealthSnapshot { impl DbHealthSnapshot { #[must_use] pub const fn ok(&self) -> bool { - self.integrity.ok && self.foreign_keys.ok + self.schema.ok && self.integrity.ok && self.foreign_keys.ok } } @@ -902,11 +903,31 @@ fn foreign_key_check_status(conn: &rusqlite::Connection) -> DbCheckResult { fn db_health_snapshot(conn: &rusqlite::Connection) -> DbHealthSnapshot { DbHealthSnapshot { + schema: schema_check_status(conn), integrity: integrity_check_status(conn), foreign_keys: foreign_key_check_status(conn), } } +fn schema_check_status(conn: &rusqlite::Connection) -> DbCheckResult { + match super::schema::verify_database_schema(conn) { + Ok(()) => DbCheckResult { + ok: true, + messages: vec![format!( + "{} baseline verified", + super::schema::baseline_schema_version() + )], + }, + Err(error) => DbCheckResult { + ok: false, + messages: vec![format!( + "{} baseline mismatch: {error}", + super::schema::baseline_schema_version() + )], + }, + } +} + fn rebuild_posts_fts(conn: &rusqlite::Connection) -> Result<()> { conn.execute_batch( r" diff --git a/src/db/boards.rs b/src/db/boards.rs index 6314a900..06855b18 100644 --- a/src/db/boards.rs +++ b/src/db/boards.rs @@ -12,14 +12,14 @@ const BOARD_ORDER_SQL: &str = "nsfw ASC, display_order ASC, id ASC"; const BOARD_GROUP_ORDER_SQL: &str = "display_order ASC, id ASC"; const BOARD_SELECT_COLUMNS: &str = "id, display_order, short_name, name, description, nsfw, \ max_threads, max_archived_threads, bump_limit, allow_images, allow_video, allow_audio, \ - max_image_size, max_video_size, max_audio_size, allow_pdf, allow_any_files, allow_tripcodes, \ + max_image_size, max_video_size, max_audio_size, max_pdf_size, allow_pdf, allow_any_files, allow_tripcodes, \ edit_window_secs, allow_editing, allow_self_delete, allow_archive, \ allow_video_embeds, allow_captcha, show_poster_ids, collapse_greentext, \ post_cooldown_secs, default_theme, banner_mode, access_mode, access_password_hash, created_at"; const BOARD_SELECT_COLUMNS_WITH_ALIAS: &str = "b.id, b.display_order, b.short_name, b.name, \ b.description, b.nsfw, b.max_threads, b.max_archived_threads, b.bump_limit, \ b.allow_images, b.allow_video, b.allow_audio, b.max_image_size, b.max_video_size, b.max_audio_size, \ - b.allow_pdf, b.allow_any_files, b.allow_tripcodes, b.edit_window_secs, b.allow_editing, \ + b.max_pdf_size, b.allow_pdf, b.allow_any_files, b.allow_tripcodes, b.edit_window_secs, b.allow_editing, \ b.allow_self_delete, b.allow_archive, b.allow_video_embeds, b.allow_captcha, \ b.show_poster_ids, b.collapse_greentext, b.post_cooldown_secs, \ b.default_theme, b.banner_mode, b.access_mode, b.access_password_hash, b.created_at"; @@ -28,8 +28,8 @@ const BOARD_SELECT_COLUMNS_WITH_ALIAS: &str = "b.id, b.display_order, b.short_na pub(super) fn map_board(row: &rusqlite::Row<'_>) -> rusqlite::Result { let short_name: String = row.get(2)?; - let banner_mode_raw: String = row.get(28)?; - let access_mode_raw: String = row.get(29)?; + let banner_mode_raw: String = row.get(29)?; + let access_mode_raw: String = row.get(30)?; let banner_mode = BoardBannerMode::from_db_str(&banner_mode_raw).unwrap_or_else(|| { tracing::warn!( target: "db", @@ -64,23 +64,24 @@ pub(super) fn map_board(row: &rusqlite::Row<'_>) -> rusqlite::Result { max_image_size: row.get(12)?, max_video_size: row.get(13)?, max_audio_size: row.get(14)?, - allow_pdf: row.get::<_, i32>(15)? != 0, - allow_any_files: row.get::<_, i32>(16)? != 0, - allow_tripcodes: row.get::<_, i32>(17)? != 0, - edit_window_secs: row.get(18)?, - allow_editing: row.get::<_, i32>(19)? != 0, - allow_self_delete: row.get::<_, i32>(20)? != 0, - allow_archive: row.get::<_, i32>(21)? != 0, - allow_video_embeds: row.get::<_, i32>(22)? != 0, - allow_captcha: row.get::<_, i32>(23)? != 0, - show_poster_ids: row.get::<_, i32>(24)? != 0, - collapse_greentext: row.get::<_, i32>(25)? != 0, - post_cooldown_secs: row.get(26)?, - default_theme: row.get(27)?, + max_pdf_size: row.get(15)?, + allow_pdf: row.get::<_, i32>(16)? != 0, + allow_any_files: row.get::<_, i32>(17)? != 0, + allow_tripcodes: row.get::<_, i32>(18)? != 0, + edit_window_secs: row.get(19)?, + allow_editing: row.get::<_, i32>(20)? != 0, + allow_self_delete: row.get::<_, i32>(21)? != 0, + allow_archive: row.get::<_, i32>(22)? != 0, + allow_video_embeds: row.get::<_, i32>(23)? != 0, + allow_captcha: row.get::<_, i32>(24)? != 0, + show_poster_ids: row.get::<_, i32>(25)? != 0, + collapse_greentext: row.get::<_, i32>(26)? != 0, + post_cooldown_secs: row.get(27)?, + default_theme: row.get(28)?, banner_mode, access_mode, - access_password_hash: row.get(30)?, - created_at: row.get(31)?, + access_password_hash: row.get(31)?, + created_at: row.get(32)?, }) } @@ -340,7 +341,7 @@ pub fn get_all_boards_with_stats( let out = stmt .query_map([], |row| { let board = map_board(row)?; - let thread_count: i64 = row.get(32)?; + let thread_count: i64 = row.get(33)?; Ok(crate::models::BoardStats { board, thread_count, @@ -516,9 +517,9 @@ pub fn create_board( "INSERT INTO boards ( display_order, short_name, name, description, nsfw, allow_images, allow_video, allow_audio, - max_image_size, max_video_size, max_audio_size + max_image_size, max_video_size, max_audio_size, max_pdf_size ) - VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0, ?6, ?7, ?8) + VALUES (?1, ?2, ?3, ?4, ?5, 1, 1, 0, ?6, ?7, ?8, ?9) RETURNING id", params![ display_order, @@ -532,6 +533,8 @@ pub fn create_board( .context("max_video_size does not fit in i64")?, i64::try_from(crate::config::CONFIG.max_audio_size) .context("max_audio_size does not fit in i64")?, + i64::try_from(crate::config::CONFIG.max_image_size) + .context("max_pdf_size does not fit in i64")?, ], |r| r.get(0), ) @@ -565,14 +568,14 @@ pub fn create_board_with_media_flags( "INSERT INTO boards ( display_order, short_name, name, description, nsfw, allow_images, allow_video, allow_audio, - max_image_size, max_video_size, max_audio_size, + max_image_size, max_video_size, max_audio_size, max_pdf_size, allow_tripcodes, allow_editing, allow_self_delete, allow_archive, allow_video_embeds, allow_captcha, show_poster_ids, collapse_greentext, post_cooldown_secs, default_theme, banner_mode, access_mode, access_password_hash ) VALUES ( - ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, + ?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, 1, 1, 1, 1, 1, 0, 1, 0, 0, '', 'inherit', 'public', '' @@ -593,6 +596,8 @@ pub fn create_board_with_media_flags( .context("max_video_size does not fit in i64")?, i64::try_from(crate::config::CONFIG.max_audio_size) .context("max_audio_size does not fit in i64")?, + i64::try_from(crate::config::CONFIG.max_image_size) + .context("max_pdf_size does not fit in i64")?, ], |r| r.get(0), ) @@ -677,6 +682,7 @@ pub fn update_board_settings( max_image_size: i64, max_video_size: i64, max_audio_size: i64, + max_pdf_size: i64, allow_pdf: bool, allow_any_files: bool, allow_tripcodes: bool, @@ -706,12 +712,12 @@ pub fn update_board_settings( "UPDATE boards SET name=?1, description=?2, nsfw=?3, bump_limit=?4, max_threads=?5, max_archived_threads=?6, allow_images=?7, allow_video=?8, allow_audio=?9, - max_image_size=?10, max_video_size=?11, max_audio_size=?12, - allow_pdf=?13, allow_any_files=?14, allow_tripcodes=?15, edit_window_secs=?16, - allow_editing=?17, allow_self_delete=?18, allow_archive=?19, allow_video_embeds=?20, - allow_captcha=?21, show_poster_ids=?22, collapse_greentext=?23, post_cooldown_secs=?24, - default_theme=?25, banner_mode=?26, access_mode=?27, access_password_hash=?28 - WHERE id=?29", + max_image_size=?10, max_video_size=?11, max_audio_size=?12, max_pdf_size=?13, + allow_pdf=?14, allow_any_files=?15, allow_tripcodes=?16, edit_window_secs=?17, + allow_editing=?18, allow_self_delete=?19, allow_archive=?20, allow_video_embeds=?21, + allow_captcha=?22, show_poster_ids=?23, collapse_greentext=?24, post_cooldown_secs=?25, + default_theme=?26, banner_mode=?27, access_mode=?28, access_password_hash=?29 + WHERE id=?30", params![ name, description, @@ -725,6 +731,7 @@ pub fn update_board_settings( max_image_size, max_video_size, max_audio_size, + max_pdf_size, i32::from(allow_pdf), i32::from(allow_any_files), i32::from(allow_tripcodes), @@ -750,12 +757,12 @@ pub fn update_board_settings( "UPDATE boards SET name=?1, description=?2, nsfw=?3, display_order=?4, bump_limit=?5, max_threads=?6, max_archived_threads=?7, allow_images=?8, allow_video=?9, allow_audio=?10, - max_image_size=?11, max_video_size=?12, max_audio_size=?13, - allow_pdf=?14, allow_any_files=?15, allow_tripcodes=?16, edit_window_secs=?17, - allow_editing=?18, allow_self_delete=?19, allow_archive=?20, allow_video_embeds=?21, - allow_captcha=?22, show_poster_ids=?23, collapse_greentext=?24, post_cooldown_secs=?25, - default_theme=?26, banner_mode=?27, access_mode=?28, access_password_hash=?29 - WHERE id=?30", + max_image_size=?11, max_video_size=?12, max_audio_size=?13, max_pdf_size=?14, + allow_pdf=?15, allow_any_files=?16, allow_tripcodes=?17, edit_window_secs=?18, + allow_editing=?19, allow_self_delete=?20, allow_archive=?21, allow_video_embeds=?22, + allow_captcha=?23, show_poster_ids=?24, collapse_greentext=?25, post_cooldown_secs=?26, + default_theme=?27, banner_mode=?28, access_mode=?29, access_password_hash=?30 + WHERE id=?31", params![ name, description, @@ -770,6 +777,7 @@ pub fn update_board_settings( max_image_size, max_video_size, max_audio_size, + max_pdf_size, i32::from(allow_pdf), i32::from(allow_any_files), i32::from(allow_tripcodes), @@ -1231,6 +1239,10 @@ mod tests { assert!(board.allow_video); assert!(board.allow_audio); assert!(!board.allow_pdf); + assert_eq!( + board.max_pdf_size, + i64::try_from(crate::config::CONFIG.max_image_size).expect("pdf size fits in i64") + ); assert!(board.allow_video_embeds); assert!(board.show_poster_ids); assert!(board.allow_editing); @@ -1279,10 +1291,17 @@ mod tests { board.max_audio_size, i64::try_from(crate::config::CONFIG.max_audio_size).expect("audio size fits in i64") ); + assert_eq!( + board.max_pdf_size, + i64::try_from(crate::config::CONFIG.max_image_size).expect("pdf size fits in i64") + ); } #[test] fn delete_board_records_durable_board_directory_cleanup() { + let _runtime_guard = crate::config::RUNTIME_LAYOUT_TEST_LOCK + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner); let temp_dir = tempfile::tempdir().expect("tempdir"); let upload_dir = temp_dir.path().join("uploads"); let board_dir = upload_dir.join("gone"); diff --git a/src/db/migrations.rs b/src/db/migrations.rs index 5a9107df..bed1f791 100644 --- a/src/db/migrations.rs +++ b/src/db/migrations.rs @@ -1,48 +1,49 @@ // src/db/migrations.rs use anyhow::{Context as _, Result}; +use rusqlite::OptionalExtension as _; -/// Post-squash schema version for the canonical baseline. -/// -/// Earlier development builds used a long numbered ladder up to v41. Fresh -/// installs now create the complete current schema directly and stamp this -/// clean baseline as v1; pre-squash databases use one legacy bridge before -/// joining the same version line. -pub(super) const POST_SQUASH_SCHEMA_VERSION: i64 = 1; +/// Database schema version for the `RustChan` 1.3.0 release baseline. +pub(super) const BASELINE_SCHEMA_VERSION: &str = "1.3.0"; -pub(super) fn read_schema_version(conn: &rusqlite::Connection) -> Result { +pub(super) fn read_schema_version(conn: &rusqlite::Connection) -> Result> { if !schema_version_table_exists(conn)? { - return Ok(0); + return Ok(None); } conn.query_row( - "SELECT COALESCE(MAX(version), 0) FROM schema_version", + "SELECT CAST(version AS TEXT) FROM schema_version LIMIT 1", [], |row| row.get(0), ) + .optional() .context("Failed to read schema_version") } -pub(super) fn stamp_schema_version(conn: &rusqlite::Connection, version: i64) -> Result<()> { - ensure_schema_version_table_has_row(conn)?; +pub(super) fn stamp_schema_version(conn: &rusqlite::Connection) -> Result<()> { conn.execute_batch("BEGIN IMMEDIATE") - .with_context(|| format!("Failed to begin schema_version stamp to v{version}"))?; + .context("Failed to begin schema_version stamp to 1.3.0")?; let result = (|| { - conn.execute("DELETE FROM schema_version", []) - .context("Failed to clear schema_version table")?; + conn.execute_batch( + "DROP TABLE IF EXISTS schema_version; + CREATE TABLE schema_version ( + version TEXT NOT NULL PRIMARY KEY + );", + ) + .context("Failed to recreate schema_version table")?; conn.execute( "INSERT INTO schema_version (version) VALUES (?1)", - rusqlite::params![version], + rusqlite::params![BASELINE_SCHEMA_VERSION], ) - .with_context(|| format!("Failed to set schema_version to v{version}"))?; + .context("Failed to set schema_version to 1.3.0")?; Ok(()) })(); match result { Ok(()) => conn .execute_batch("COMMIT") - .with_context(|| format!("Failed to commit schema_version stamp to v{version}")), + .context("Failed to commit schema_version stamp to 1.3.0"), Err(error) => { let _ = conn.execute_batch("ROLLBACK"); Err(error) @@ -50,19 +51,6 @@ pub(super) fn stamp_schema_version(conn: &rusqlite::Connection, version: i64) -> } } -fn ensure_schema_version_table_has_row(conn: &rusqlite::Connection) -> Result<()> { - conn.execute_batch( - "CREATE TABLE IF NOT EXISTS schema_version ( - version INTEGER NOT NULL DEFAULT 0, - UNIQUE(version) - ); - INSERT INTO schema_version (version) - SELECT 0 - WHERE NOT EXISTS (SELECT 1 FROM schema_version);", - ) - .context("Failed to create schema_version table") -} - fn schema_version_table_exists(conn: &rusqlite::Connection) -> Result { conn.query_row( "SELECT EXISTS ( @@ -78,17 +66,20 @@ fn schema_version_table_exists(conn: &rusqlite::Connection) -> Result { #[cfg(test)] mod tests { - use super::{read_schema_version, stamp_schema_version, POST_SQUASH_SCHEMA_VERSION}; + use super::{read_schema_version, stamp_schema_version, BASELINE_SCHEMA_VERSION}; #[test] - fn missing_schema_version_reads_as_legacy_zero() { + fn missing_schema_version_reads_as_unversioned() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); - assert_eq!(read_schema_version(&conn).expect("read schema version"), 0); + assert_eq!( + read_schema_version(&conn).expect("read schema version"), + None + ); } #[test] - fn stamp_replaces_existing_version_with_post_squash_baseline() { + fn stamp_replaces_existing_version_with_release_baseline() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); conn.execute_batch( "CREATE TABLE schema_version ( @@ -99,11 +90,16 @@ mod tests { ) .expect("create legacy schema_version"); - stamp_schema_version(&conn, POST_SQUASH_SCHEMA_VERSION).expect("stamp schema version"); + stamp_schema_version(&conn).expect("stamp schema version"); assert_eq!( read_schema_version(&conn).expect("read stamped schema version"), - POST_SQUASH_SCHEMA_VERSION + Some(BASELINE_SCHEMA_VERSION.to_owned()) ); } + + #[test] + fn baseline_schema_version_matches_package_release() { + assert_eq!(BASELINE_SCHEMA_VERSION, env!("CARGO_PKG_VERSION")); + } } diff --git a/src/db/mod.rs b/src/db/mod.rs index 66c5cde2..d59e1391 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -14,6 +14,7 @@ mod migrations; mod pool; pub mod posts; mod schema; +pub mod setup; pub mod themes; pub mod threads; mod types; @@ -30,10 +31,41 @@ pub use banners::*; pub use boards::*; pub use fs_ops::*; pub use posts::*; +pub use setup::*; pub use themes::*; pub use threads::*; pub use user_thread_prefs::*; +/// Return the database schema version for the current release baseline. +#[must_use] +pub const fn baseline_schema_version() -> &'static str { + schema::baseline_schema_version() +} + +/// Verify that the open database exactly matches the current release baseline. +/// +/// # Errors +/// Returns an error if integrity checks fail, schema objects differ from the +/// baseline, or the recorded schema version is not current. +pub fn verify_database_schema(conn: &rusqlite::Connection) -> Result<()> { + schema::verify_database_schema(conn) +} + +/// Verify the database baseline and stamp the current schema version when safe. +/// +/// # Errors +/// Returns an error if the database does not structurally match the current +/// release baseline or cannot be stamped with the current schema version. +pub fn normalize_database_schema_version(conn: &rusqlite::Connection) -> Result<()> { + schema::normalize_database_schema_version(conn) +} + +/// Return a human-readable database schema status label for diagnostics. +#[must_use] +pub fn database_schema_status_label(conn: &rusqlite::Connection) -> String { + schema::database_schema_status_label(conn) +} + #[derive(Debug, Clone, PartialEq, Eq)] pub struct DeletePathsResult { pub paths: Vec, diff --git a/src/db/posts.rs b/src/db/posts.rs index 2cb73cb6..f95de126 100644 --- a/src/db/posts.rs +++ b/src/db/posts.rs @@ -1122,6 +1122,8 @@ pub fn cleanup_expired_poll_votes( // Jobs flow through: pending → running → done | failed // claim_next_job uses UPDATE … RETURNING for atomic claim with no TOCTOU race. +const FAILED_BACKGROUND_JOBS_ACK_ID_KEY: &str = "failed_background_jobs_acknowledged_through_id"; + /// Persist a new job in the pending state. Returns the new row id. /// /// INSERT … RETURNING id replaces execute + `last_insert_rowid()`. @@ -1244,14 +1246,15 @@ pub fn pending_job_count(conn: &rusqlite::Connection) -> Result { /// # Errors /// Returns an error if the database queries fail. pub fn background_job_summary(conn: &rusqlite::Connection) -> Result { + let acknowledged_failed_id = acknowledged_failed_background_job_id(conn)?; let (running, queued, recent_completed, failed) = conn.query_row( "SELECT SUM(CASE WHEN status = 'running' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'pending' THEN 1 ELSE 0 END), SUM(CASE WHEN status = 'done' AND updated_at >= unixepoch() - 86400 THEN 1 ELSE 0 END), - SUM(CASE WHEN status = 'failed' THEN 1 ELSE 0 END) + SUM(CASE WHEN status = 'failed' AND id > ?1 THEN 1 ELSE 0 END) FROM background_jobs", - [], + params![acknowledged_failed_id], |row| { Ok(( row.get::<_, Option>(0)?.unwrap_or(0), @@ -1269,6 +1272,37 @@ pub fn background_job_summary(conn: &rusqlite::Connection) -> Result Result { + let value = super::get_site_setting(conn, FAILED_BACKGROUND_JOBS_ACK_ID_KEY)?; + Ok(value + .as_deref() + .and_then(|value| value.parse::().ok()) + .unwrap_or(0) + .max(0)) +} + +/// Mark all currently failed background jobs as acknowledged for admin counters. +/// +/// This intentionally preserves the `background_jobs` rows, payloads, and +/// errors. Only the admin attention counter is reset until a newer failure +/// appears. +/// +/// # Errors +/// Returns an error if the database queries or site-setting update fail. +pub fn acknowledge_failed_background_jobs(conn: &rusqlite::Connection) -> Result { + let max_failed_id = conn.query_row( + "SELECT COALESCE(MAX(id), 0) FROM background_jobs WHERE status = 'failed'", + [], + |row| row.get::<_, i64>(0), + )?; + super::set_site_setting( + conn, + FAILED_BACKGROUND_JOBS_ACK_ID_KEY, + &max_failed_id.to_string(), + )?; + Ok(max_failed_id) +} + /// Return recent terminal background jobs for bounded admin diagnostics. /// /// # Errors @@ -1573,12 +1607,13 @@ pub fn delete_file_hash_by_path(conn: &rusqlite::Connection, file_path: &str) -> #[cfg(test)] mod tests { use super::{ - claim_next_job, count_posts_by_media_processing_state, count_search_results, get_post, - get_post_submission, get_posts_for_thread, is_stale_media_target_error, - recent_background_jobs, record_post_submission, recover_interrupted_background_jobs, - replace_transcoded_media, search_posts, search_terms, self_delete_post, - set_post_media_processing_state, to_fts_query, update_post_thumb_path, SelfDeleteOutcome, - MEDIA_PROCESSING_FAILED, MEDIA_PROCESSING_PENDING, + acknowledge_failed_background_jobs, background_job_summary, claim_next_job, + count_posts_by_media_processing_state, count_search_results, get_post, get_post_submission, + get_posts_for_thread, is_stale_media_target_error, recent_background_jobs, + record_post_submission, recover_interrupted_background_jobs, replace_transcoded_media, + search_posts, search_terms, self_delete_post, set_post_media_processing_state, + to_fts_query, update_post_thumb_path, SelfDeleteOutcome, MEDIA_PROCESSING_FAILED, + MEDIA_PROCESSING_PENDING, }; use crate::db::{ create_board, create_reply_with_thread_update, create_thread_with_optional_poll, @@ -1709,6 +1744,53 @@ mod tests { assert!(older < newer); } + #[test] + fn failed_background_job_acknowledgement_preserves_history() { + let conn = test_conn(); + let payload = r#"{"t":"SpamCheck","d":{"post_id":1,"ip_hash":"hash","body_len":5}}"#; + insert_background_job(&conn, "spam_check", payload, "failed", 3, Some("older")); + let acknowledged = + insert_background_job(&conn, "thread_prune", payload, "failed", 3, Some("newer")); + + assert_eq!( + background_job_summary(&conn) + .expect("summary before ack") + .failed, + 2 + ); + assert_eq!( + acknowledge_failed_background_jobs(&conn).expect("acknowledge failed jobs"), + acknowledged + ); + assert_eq!( + background_job_summary(&conn) + .expect("summary after ack") + .failed, + 0 + ); + assert_eq!( + recent_background_jobs(&conn, "failed", 10) + .expect("recent failed jobs") + .len(), + 2 + ); + + insert_background_job( + &conn, + "spam_check", + payload, + "failed", + 3, + Some("new failure"), + ); + assert_eq!( + background_job_summary(&conn) + .expect("summary after new failure") + .failed, + 1 + ); + } + #[test] fn search_query_ignores_punctuation_only_input() { assert_eq!(to_fts_query("'"), None); diff --git a/src/db/schema.rs b/src/db/schema.rs index 84926c18..00da1bc5 100644 --- a/src/db/schema.rs +++ b/src/db/schema.rs @@ -1,8 +1,9 @@ // src/db/schema.rs -use anyhow::{Context as _, Result}; +use anyhow::{bail, Context as _, Result}; +use std::collections::{BTreeMap, BTreeSet}; -use super::migrations::{read_schema_version, stamp_schema_version, POST_SQUASH_SCHEMA_VERSION}; +use super::migrations::{read_schema_version, stamp_schema_version, BASELINE_SCHEMA_VERSION}; const BASE_SCHEMA_SQL: &str = " CREATE TABLE IF NOT EXISTS boards ( @@ -22,6 +23,7 @@ const BASE_SCHEMA_SQL: &str = " max_image_size INTEGER NOT NULL DEFAULT 8388608, max_video_size INTEGER NOT NULL DEFAULT 52428800, max_audio_size INTEGER NOT NULL DEFAULT 157286400, + max_pdf_size INTEGER NOT NULL DEFAULT 157286400, allow_pdf INTEGER NOT NULL DEFAULT 0, allow_any_files INTEGER NOT NULL DEFAULT 0, edit_window_secs INTEGER NOT NULL DEFAULT 0, @@ -309,230 +311,66 @@ const INDEX_SCHEMA_SQL: &str = " ON post_submissions(created_at ASC); "; -const LEGACY_BASELINE_COLUMN_ADDITIONS: [(&str, &str, &str); 33] = [ - ( - "boards", - "display_order", - "ALTER TABLE boards ADD COLUMN display_order INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "max_archived_threads", - "ALTER TABLE boards ADD COLUMN max_archived_threads INTEGER NOT NULL DEFAULT 150", - ), - ( - "boards", - "allow_video", - "ALTER TABLE boards ADD COLUMN allow_video INTEGER NOT NULL DEFAULT 1", - ), - ( - "boards", - "allow_tripcodes", - "ALTER TABLE boards ADD COLUMN allow_tripcodes INTEGER NOT NULL DEFAULT 1", - ), - ( - "boards", - "allow_images", - "ALTER TABLE boards ADD COLUMN allow_images INTEGER NOT NULL DEFAULT 1", - ), - ( - "boards", - "allow_audio", - "ALTER TABLE boards ADD COLUMN allow_audio INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "max_image_size", - "ALTER TABLE boards ADD COLUMN max_image_size INTEGER NOT NULL DEFAULT 8388608", - ), - ( - "boards", - "max_video_size", - "ALTER TABLE boards ADD COLUMN max_video_size INTEGER NOT NULL DEFAULT 52428800", - ), - ( - "boards", - "max_audio_size", - "ALTER TABLE boards ADD COLUMN max_audio_size INTEGER NOT NULL DEFAULT 157286400", - ), - ( - "boards", - "allow_pdf", - "ALTER TABLE boards ADD COLUMN allow_pdf INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "allow_any_files", - "ALTER TABLE boards ADD COLUMN allow_any_files INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "edit_window_secs", - "ALTER TABLE boards ADD COLUMN edit_window_secs INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "allow_editing", - "ALTER TABLE boards ADD COLUMN allow_editing INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "allow_self_delete", - "ALTER TABLE boards ADD COLUMN allow_self_delete INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "allow_archive", - "ALTER TABLE boards ADD COLUMN allow_archive INTEGER NOT NULL DEFAULT 1", - ), - ( - "boards", - "allow_video_embeds", - "ALTER TABLE boards ADD COLUMN allow_video_embeds INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "allow_captcha", - "ALTER TABLE boards ADD COLUMN allow_captcha INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "show_poster_ids", - "ALTER TABLE boards ADD COLUMN show_poster_ids INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "collapse_greentext", - "ALTER TABLE boards ADD COLUMN collapse_greentext INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "post_cooldown_secs", - "ALTER TABLE boards ADD COLUMN post_cooldown_secs INTEGER NOT NULL DEFAULT 0", - ), - ( - "boards", - "default_theme", - "ALTER TABLE boards ADD COLUMN default_theme TEXT NOT NULL DEFAULT ''", - ), - ( - "boards", - "banner_mode", - "ALTER TABLE boards ADD COLUMN banner_mode TEXT NOT NULL DEFAULT 'inherit' - CHECK (banner_mode IN ('inherit', 'none', 'override'))", - ), - ( - "boards", - "access_mode", - "ALTER TABLE boards ADD COLUMN access_mode TEXT NOT NULL DEFAULT 'public' - CHECK (access_mode IN ('public', 'view_password', 'post_password'))", - ), - ( - "boards", - "access_password_hash", - "ALTER TABLE boards ADD COLUMN access_password_hash TEXT NOT NULL DEFAULT ''", - ), - ( - "threads", - "archived", - "ALTER TABLE threads ADD COLUMN archived INTEGER NOT NULL DEFAULT 0", - ), - ( - "posts", - "media_type", - "ALTER TABLE posts ADD COLUMN media_type TEXT", - ), - ( - "posts", - "audio_file_path", - "ALTER TABLE posts ADD COLUMN audio_file_path TEXT", - ), - ( - "posts", - "audio_file_name", - "ALTER TABLE posts ADD COLUMN audio_file_name TEXT", - ), - ( - "posts", - "audio_file_size", - "ALTER TABLE posts ADD COLUMN audio_file_size INTEGER", - ), - ( - "posts", - "audio_mime_type", - "ALTER TABLE posts ADD COLUMN audio_mime_type TEXT", - ), - ( - "posts", - "edited_at", - "ALTER TABLE posts ADD COLUMN edited_at INTEGER", - ), - ( - "posts", - "media_processing_state", - "ALTER TABLE posts ADD COLUMN media_processing_state TEXT NOT NULL DEFAULT ''", - ), - ( - "posts", - "media_processing_error", - "ALTER TABLE posts ADD COLUMN media_processing_error TEXT", - ), +const LEGACY_THEME_SORT_INDEX: &str = "idx_themes_enabled_sort"; +const LEGACY_BOARD_ZERO_DEFAULT_COLUMNS: [&str; 4] = [ + "allow_editing", + "allow_self_delete", + "allow_video_embeds", + "show_poster_ids", ]; pub(super) fn install_or_migrate_schema(conn: &rusqlite::Connection) -> Result<()> { - let fresh_database = is_fresh_database(conn)?; - if fresh_database { - install_post_squash_baseline(conn)?; - } else { - let schema_version = read_schema_version(conn)?; - if schema_version == POST_SQUASH_SCHEMA_VERSION && has_post_squash_baseline_markers(conn)? { - finish_baseline_schema(conn)?; - } else { - upgrade_pre_squash_database_to_v1(conn)?; + if is_fresh_database(conn)? { + install_baseline_schema(conn)?; + return Ok(()); + } + + normalize_database_schema_version(conn) +} + +pub(super) const fn baseline_schema_version() -> &'static str { + BASELINE_SCHEMA_VERSION +} + +pub(super) fn verify_database_schema(conn: &rusqlite::Connection) -> Result<()> { + verify_database_schema_structure(conn)?; + match read_schema_version(conn)? { + Some(version) if version == BASELINE_SCHEMA_VERSION => Ok(()), + Some(version) => { + bail!("database schema version is {version}, expected {BASELINE_SCHEMA_VERSION}") } + None => bail!("database schema version is missing, expected {BASELINE_SCHEMA_VERSION}"), } +} - Ok(()) +pub(super) fn normalize_database_schema_version(conn: &rusqlite::Connection) -> Result<()> { + repair_known_legacy_baseline_drift(conn)?; + verify_database_schema_structure(conn)?; + if read_schema_version(conn)?.as_deref() != Some(BASELINE_SCHEMA_VERSION) { + stamp_schema_version(conn)?; + } + verify_database_schema(conn) } -fn install_post_squash_baseline(conn: &rusqlite::Connection) -> Result<()> { - finish_baseline_schema(conn)?; - stamp_schema_version(conn, POST_SQUASH_SCHEMA_VERSION) +pub(super) fn database_schema_status_label(conn: &rusqlite::Connection) -> String { + match verify_database_schema(conn) { + Ok(()) => format!("{BASELINE_SCHEMA_VERSION} baseline verified"), + Err(error) => format!("{BASELINE_SCHEMA_VERSION} baseline mismatch: {error}"), + } } -fn finish_baseline_schema(conn: &rusqlite::Connection) -> Result<()> { - create_base_tables(conn)?; - // Post-squash databases can already be stamped at v1 while still missing - // additive baseline columns introduced later. Keep the baseline repair - // idempotent so existing installs receive new board/post/thread columns. - ensure_legacy_columns_for_baseline(conn)?; - create_indexes(conn)?; - ensure_reports_table_integrity(conn)?; - ensure_posts_ip_hash_nullable(conn)?; - backfill_media_type(conn)?; - // The posts table may be rebuilt by compatibility repairs above, so create - // FTS and post triggers only after those table-level repairs are complete. - ensure_posts_search_index(conn)?; - ensure_post_invariants(conn)?; - ensure_board_access_invariants(conn)?; - Ok(()) +fn install_baseline_schema(conn: &rusqlite::Connection) -> Result<()> { + create_baseline_schema_objects(conn)?; + stamp_schema_version(conn)?; + verify_database_schema(conn) } -fn upgrade_pre_squash_database_to_v1(conn: &rusqlite::Connection) -> Result<()> { - // Legacy databases may have any old historical schema_version up through - // the removed early-development ladder. Bring them to the same canonical - // post-squash baseline as a fresh install, then stamp v1 only after every - // compatibility step succeeds. +fn create_baseline_schema_objects(conn: &rusqlite::Connection) -> Result<()> { create_base_tables(conn)?; - ensure_legacy_columns_for_baseline(conn)?; create_indexes(conn)?; - ensure_reports_table_integrity(conn)?; - ensure_posts_ip_hash_nullable(conn)?; - backfill_media_type(conn)?; ensure_posts_search_index(conn)?; ensure_post_invariants(conn)?; - ensure_board_access_invariants(conn)?; - stamp_schema_version(conn, POST_SQUASH_SCHEMA_VERSION) + ensure_board_access_invariants(conn) } fn create_base_tables(conn: &rusqlite::Connection) -> Result<()> { @@ -552,71 +390,11 @@ fn is_fresh_database(conn: &rusqlite::Connection) -> Result { .context("Failed to detect whether database is fresh") } -fn has_post_squash_baseline_markers(conn: &rusqlite::Connection) -> Result { - Ok(object_exists(conn, "table", "banner_assets")? - && column_exists(conn, "boards", "banner_mode")? - && column_exists(conn, "boards", "access_mode")? - && column_exists(conn, "threads", "archived")? - && column_exists(conn, "posts", "media_processing_state")?) -} - -fn object_exists(conn: &rusqlite::Connection, kind: &str, name: &str) -> Result { - conn.query_row( - "SELECT EXISTS ( - SELECT 1 - FROM sqlite_master - WHERE type = ?1 AND name = ?2 - )", - rusqlite::params![kind, name], - |row| row.get(0), - ) - .with_context(|| format!("Failed to inspect schema object {kind}:{name}")) -} - fn create_indexes(conn: &rusqlite::Connection) -> Result<()> { conn.execute_batch(INDEX_SCHEMA_SQL) .context("Schema index creation failed") } -fn ensure_legacy_columns_for_baseline(conn: &rusqlite::Connection) -> Result<()> { - conn.execute_batch("BEGIN IMMEDIATE") - .context("Begin legacy baseline column bridge failed")?; - let result = (|| { - for (table, column, sql) in LEGACY_BASELINE_COLUMN_ADDITIONS { - ensure_column(conn, table, column, sql)?; - } - conn.execute_batch( - "UPDATE boards - SET display_order = id - WHERE display_order = 0; - - UPDATE boards - SET collapse_greentext = CASE - WHEN EXISTS ( - SELECT 1 - FROM site_settings - WHERE key = 'collapse_greentext' - AND (value = '1' OR lower(value) = 'true') - ) THEN 1 - ELSE 0 - END - WHERE collapse_greentext = 0;", - ) - .context("Backfill legacy board baseline columns failed")?; - Ok(()) - })(); - - match result { - Ok(()) => conn - .execute_batch("COMMIT") - .context("Commit legacy baseline column bridge failed"), - Err(error) => { - let _ = conn.execute_batch("ROLLBACK"); - Err(error) - } - } -} - fn ensure_posts_search_index(conn: &rusqlite::Connection) -> Result<()> { conn.execute_batch( r" @@ -722,47 +500,270 @@ fn ensure_board_access_invariants(conn: &rusqlite::Connection) -> Result<()> { .context("Board access invariant creation failed") } -fn read_column_notnull(conn: &rusqlite::Connection, table: &str, column: &str) -> Result { - let query = format!("SELECT \"notnull\" FROM pragma_table_info('{table}') WHERE name = ?1"); - let notnull: i64 = conn - .query_row(&query, [column], |row| row.get(0)) - .with_context(|| format!("Failed to read {table}.{column} nullability"))?; - Ok(notnull == 1) -} +fn repair_known_legacy_baseline_drift(conn: &rusqlite::Connection) -> Result<()> { + if !is_known_legacy_schema_version(read_schema_version(conn)?.as_deref()) { + return Ok(()); + } -fn ensure_column( - conn: &rusqlite::Connection, - table: &str, - column: &str, - add_column_sql: &str, -) -> Result<()> { - if column_exists(conn, table, column)? { + let expected = expected_schema_shape()?; + let actual = schema_shape(conn)?; + if !can_repair_known_legacy_baseline_drift(&expected, &actual) { return Ok(()); } - conn.execute_batch(add_column_sql) - .with_context(|| format!("Add legacy baseline column {table}.{column} failed")) + if actual.objects.contains_key(LEGACY_THEME_SORT_INDEX) { + conn.execute_batch("DROP INDEX IF EXISTS idx_themes_enabled_sort") + .context("Drop legacy themes sort index failed")?; + } + + if boards_table_requires_baseline_rebuild(&expected, &actual) { + rebuild_boards_table_for_baseline(conn)?; + ensure_board_access_invariants(conn)?; + } + + Ok(()) } -fn column_exists(conn: &rusqlite::Connection, table: &str, column: &str) -> Result { - conn.query_row( - "SELECT EXISTS ( - SELECT 1 - FROM pragma_table_info(?1) - WHERE name = ?2 - )", - rusqlite::params![table, column], - |row| row.get(0), +fn is_known_legacy_schema_version(version: Option<&str>) -> bool { + match version.and_then(|value| value.parse::().ok()) { + Some(1..=41) => true, + Some(_) | None => false, + } +} + +fn can_repair_known_legacy_baseline_drift(expected: &SchemaShape, actual: &SchemaShape) -> bool { + schema_objects_are_legacy_repairable(expected, actual) + && tables_are_legacy_repairable(expected, actual) +} + +fn schema_objects_are_legacy_repairable(expected: &SchemaShape, actual: &SchemaShape) -> bool { + for (name, expected_object) in &expected.objects { + let Some(actual_object) = actual.objects.get(name) else { + return false; + }; + if actual_object.kind != expected_object.kind { + return false; + } + if matches!(expected_object.kind.as_str(), "index" | "trigger") + && actual_object.sql != expected_object.sql + { + return false; + } + } + + for (name, actual_object) in &actual.objects { + if expected.objects.contains_key(name) { + continue; + } + if name == LEGACY_THEME_SORT_INDEX + && actual_object.kind == "index" + && actual_object + .sql + .as_deref() + .is_some_and(is_legacy_theme_sort_index) + { + continue; + } + return false; + } + + true +} + +fn is_legacy_theme_sort_index(sql: &str) -> bool { + sql == normalize_schema_sql( + "CREATE INDEX idx_themes_enabled_sort ON themes(enabled, sort_order, slug)", ) - .with_context(|| format!("Failed to inspect schema column {table}.{column}")) } -fn run_structural_migration( +fn tables_are_legacy_repairable(expected: &SchemaShape, actual: &SchemaShape) -> bool { + for (table, expected_table) in &expected.tables { + let Some(actual_table) = actual.tables.get(table) else { + return false; + }; + let repairable = match table.as_str() { + "boards" => boards_table_is_legacy_repairable(expected_table, actual_table), + "themes" => themes_table_is_legacy_repairable(expected_table, actual_table), + _ => actual_table == expected_table, + }; + if !repairable { + return false; + } + } + + true +} + +fn themes_table_is_legacy_repairable(expected: &TableShape, actual: &TableShape) -> bool { + if actual.columns != expected.columns || actual.foreign_keys != expected.foreign_keys { + return false; + } + + let mut actual_indexes = actual.indexes.clone(); + actual_indexes.remove(LEGACY_THEME_SORT_INDEX); + actual_indexes == expected.indexes +} + +fn boards_table_is_legacy_repairable(expected: &TableShape, actual: &TableShape) -> bool { + actual.foreign_keys == expected.foreign_keys + && legacy_board_columns_match(expected, actual) + && legacy_board_indexes_are_repairable(actual) +} + +fn legacy_board_columns_match(expected: &TableShape, actual: &TableShape) -> bool { + if actual.columns.len() != expected.columns.len() { + return false; + } + + for (column, expected_shape) in &expected.columns { + let Some(actual_shape) = actual.columns.get(column) else { + return false; + }; + if !legacy_board_column_matches(column, expected_shape, actual_shape) { + return false; + } + } + + true +} + +fn legacy_board_column_matches(column: &str, expected: &ColumnShape, actual: &ColumnShape) -> bool { + if actual == expected { + return true; + } + if !LEGACY_BOARD_ZERO_DEFAULT_COLUMNS.contains(&column) { + return false; + } + + let mut legacy_expected = expected.clone(); + legacy_expected.default_value = Some("0".to_owned()); + actual == &legacy_expected +} + +fn legacy_board_indexes_are_repairable(actual: &TableShape) -> bool { + actual.indexes.len() == 1 + && actual.indexes.values().any(|index| { + index.unique + && index.origin == "u" + && !index.partial + && index.where_clause.is_none() + && index.columns.len() == 1 + && index + .columns + .first() + .and_then(|column| column.name.as_deref()) + == Some("short_name") + }) +} + +fn boards_table_requires_baseline_rebuild(expected: &SchemaShape, actual: &SchemaShape) -> bool { + actual.tables.get("boards") != expected.tables.get("boards") + || required_fragments_missing_for_table(actual, "boards") +} + +fn required_fragments_missing_for_table(shape: &SchemaShape, table: &str) -> bool { + let Some(object) = shape.objects.get(table) else { + return true; + }; + let Some(sql) = object.sql.as_deref() else { + return true; + }; + + REQUIRED_TABLE_SQL_FRAGMENTS + .iter() + .filter(|(fragment_table, _)| *fragment_table == table) + .any(|(_, fragment)| !sql.contains(&normalize_schema_sql(fragment))) +} + +fn rebuild_boards_table_for_baseline(conn: &rusqlite::Connection) -> Result<()> { + run_structural_schema_repair( + conn, + r" + CREATE TABLE boards_new ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + display_order INTEGER NOT NULL DEFAULT 0, + short_name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + nsfw INTEGER NOT NULL DEFAULT 0, + max_threads INTEGER NOT NULL DEFAULT 150, + max_archived_threads INTEGER NOT NULL DEFAULT 150, + bump_limit INTEGER NOT NULL DEFAULT 500, + allow_video INTEGER NOT NULL DEFAULT 1, + allow_tripcodes INTEGER NOT NULL DEFAULT 1, + allow_images INTEGER NOT NULL DEFAULT 1, + allow_audio INTEGER NOT NULL DEFAULT 0, + max_image_size INTEGER NOT NULL DEFAULT 8388608, + max_video_size INTEGER NOT NULL DEFAULT 52428800, + max_audio_size INTEGER NOT NULL DEFAULT 157286400, + max_pdf_size INTEGER NOT NULL DEFAULT 157286400, + allow_pdf INTEGER NOT NULL DEFAULT 0, + allow_any_files INTEGER NOT NULL DEFAULT 0, + edit_window_secs INTEGER NOT NULL DEFAULT 0, + allow_editing INTEGER NOT NULL DEFAULT 1, + allow_self_delete INTEGER NOT NULL DEFAULT 1, + allow_archive INTEGER NOT NULL DEFAULT 1, + allow_video_embeds INTEGER NOT NULL DEFAULT 1, + allow_captcha INTEGER NOT NULL DEFAULT 0, + show_poster_ids INTEGER NOT NULL DEFAULT 1, + collapse_greentext INTEGER NOT NULL DEFAULT 0, + post_cooldown_secs INTEGER NOT NULL DEFAULT 0, + default_theme TEXT NOT NULL DEFAULT '', + banner_mode TEXT NOT NULL DEFAULT 'inherit' + CHECK (banner_mode IN ('inherit', 'none', 'override')), + access_mode TEXT NOT NULL DEFAULT 'public' + CHECK (access_mode IN ('public', 'view_password', 'post_password')), + access_password_hash TEXT NOT NULL DEFAULT '', + created_at INTEGER NOT NULL DEFAULT (unixepoch()) + ); + + INSERT INTO boards_new ( + id, display_order, short_name, name, description, nsfw, max_threads, + max_archived_threads, bump_limit, allow_video, allow_tripcodes, + allow_images, allow_audio, max_image_size, max_video_size, + max_audio_size, max_pdf_size, allow_pdf, allow_any_files, + edit_window_secs, allow_editing, allow_self_delete, allow_archive, + allow_video_embeds, allow_captcha, show_poster_ids, + collapse_greentext, post_cooldown_secs, default_theme, + banner_mode, access_mode, access_password_hash, created_at + ) + SELECT + id, display_order, short_name, name, description, nsfw, max_threads, + max_archived_threads, bump_limit, allow_video, allow_tripcodes, + allow_images, allow_audio, max_image_size, max_video_size, + max_audio_size, max_pdf_size, allow_pdf, allow_any_files, + edit_window_secs, allow_editing, allow_self_delete, allow_archive, + allow_video_embeds, allow_captcha, show_poster_ids, + collapse_greentext, post_cooldown_secs, default_theme, + CASE + WHEN banner_mode IN ('inherit', 'none', 'override') THEN banner_mode + ELSE 'inherit' + END, + CASE + WHEN access_mode IN ('public', 'view_password', 'post_password') THEN access_mode + ELSE 'view_password' + END, + access_password_hash, created_at + FROM boards; + + DROP TABLE boards; + ALTER TABLE boards_new RENAME TO boards; + ", + "Structural migration: rebuild boards table for 1.3.0 baseline failed", + "Applied structural migration: boards table matches 1.3.0 baseline", + ) +} + +fn run_structural_schema_repair( conn: &rusqlite::Connection, sql: &str, failure_context: &str, success_log: &str, ) -> Result<()> { + let foreign_keys_enabled = conn + .query_row("PRAGMA foreign_keys", [], |row| row.get::<_, i64>(0)) + .context("Read foreign key setting before schema repair failed")? + != 0; conn.execute_batch("PRAGMA foreign_keys = OFF;") .with_context(|| format!("Disable foreign keys for {failure_context}"))?; conn.execute_batch("BEGIN IMMEDIATE") @@ -772,222 +773,485 @@ fn run_structural_migration( Ok(()) => { if let Err(error) = conn.execute_batch("COMMIT") { let _ = conn.execute_batch("ROLLBACK"); - let _ = conn.execute_batch("PRAGMA foreign_keys = ON;"); + restore_foreign_keys(conn, foreign_keys_enabled); return Err(error).with_context(|| format!("Commit {failure_context}")); } - conn.execute_batch("PRAGMA foreign_keys = ON;") - .with_context(|| format!("Re-enable foreign keys after {failure_context}"))?; + restore_foreign_keys(conn, foreign_keys_enabled); tracing::info!(target: "db", "{success_log}"); Ok(()) } Err(error) => { let _ = conn.execute_batch("ROLLBACK"); - let _ = conn.execute_batch("PRAGMA foreign_keys = ON;"); + restore_foreign_keys(conn, foreign_keys_enabled); Err(error).context(failure_context.to_owned()) } } } -fn reports_has_full_foreign_keys(conn: &rusqlite::Connection) -> Result { +fn restore_foreign_keys(conn: &rusqlite::Connection, enabled: bool) { + let statement = if enabled { + "PRAGMA foreign_keys = ON;" + } else { + "PRAGMA foreign_keys = OFF;" + }; + let _ = conn.execute_batch(statement); +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SchemaShape { + objects: BTreeMap, + tables: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct SchemaObject { + kind: String, + sql: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct TableShape { + columns: BTreeMap, + foreign_keys: BTreeSet, + indexes: BTreeMap, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct ColumnShape { + decl_type: String, + not_null: bool, + default_value: Option, + primary_key_position: i64, + hidden: i64, +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +struct ForeignKeyShape { + table_name: String, + from_column: String, + to_column: Option, + on_update: String, + on_delete: String, + match_rule: String, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IndexShape { + unique: bool, + origin: String, + partial: bool, + columns: Vec, + where_clause: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct IndexColumnShape { + seqno: i64, + cid: i64, + name: Option, + descending: bool, + collation: Option, +} + +fn verify_database_schema_structure(conn: &rusqlite::Connection) -> Result<()> { + let expected = expected_schema_shape()?; + let actual = schema_shape(conn)?; + let mut issues = Vec::new(); + + compare_schema_objects(&expected, &actual, &mut issues); + compare_tables(&expected, &actual, &mut issues); + verify_required_sql_fragments(&actual, &mut issues); + verify_sqlite_health(conn, &mut issues); + + if issues.is_empty() { + Ok(()) + } else { + bail!( + "database does not match RustChan {BASELINE_SCHEMA_VERSION} baseline: {}", + issues.join("; ") + ) + } +} + +fn expected_schema_shape() -> Result { + let expected = rusqlite::Connection::open_in_memory() + .context("Open in-memory baseline schema connection failed")?; + create_baseline_schema_objects(&expected)?; + schema_shape(&expected) +} + +fn schema_shape(conn: &rusqlite::Connection) -> Result { + let objects = schema_objects(conn)?; + let mut tables = BTreeMap::new(); + for (name, object) in &objects { + if object.kind == "table" { + tables.insert(name.clone(), table_shape(conn, name)?); + } + } + Ok(SchemaShape { objects, tables }) +} + +fn schema_objects(conn: &rusqlite::Connection) -> Result> { let mut stmt = conn - .prepare("SELECT \"from\", \"table\", on_delete FROM pragma_foreign_key_list('reports')") - .context("Prepare reports foreign-key inspection failed")?; - let foreign_keys = stmt + .prepare( + "SELECT type, name, sql + FROM sqlite_schema + WHERE type IN ('table', 'index', 'trigger', 'view') + AND name NOT LIKE 'sqlite_%' + AND name <> 'schema_version' + ORDER BY type, name", + ) + .context("Prepare schema object inspection failed")?; + let rows = stmt .query_map([], |row| { + let kind: String = row.get(0)?; + let name: String = row.get(1)?; + let sql: Option = row.get(2)?; Ok(( - row.get::<_, String>(0)?, - row.get::<_, String>(1)?, - row.get::<_, String>(2)?, + name, + SchemaObject { + kind, + sql: sql.map(|sql| normalize_schema_sql(&sql)), + }, )) }) - .context("Query reports foreign keys failed")? - .collect::>>() - .context("Read reports foreign keys failed")?; - - Ok(foreign_keys.iter().any(|(from, table, on_delete)| { - from == "post_id" && table == "posts" && on_delete.eq_ignore_ascii_case("CASCADE") - }) && foreign_keys.iter().any(|(from, table, on_delete)| { - from == "thread_id" && table == "threads" && on_delete.eq_ignore_ascii_case("CASCADE") - }) && foreign_keys.iter().any(|(from, table, on_delete)| { - from == "board_id" && table == "boards" && on_delete.eq_ignore_ascii_case("CASCADE") - }) && foreign_keys.iter().any(|(from, table, on_delete)| { - from == "resolved_by" - && table == "admin_users" - && on_delete.eq_ignore_ascii_case("SET NULL") - })) + .context("Query schema objects failed")?; + + let mut objects = BTreeMap::new(); + for row in rows { + let (name, object) = row.context("Read schema object failed")?; + objects.insert(name, object); + } + Ok(objects) } -fn ensure_reports_table_integrity(conn: &rusqlite::Connection) -> Result<()> { - if reports_has_full_foreign_keys(conn)? { - conn.execute_batch( - "CREATE UNIQUE INDEX IF NOT EXISTS idx_reports_open_unique - ON reports(post_id, reporter_hash) - WHERE status = 'open';", +fn table_shape(conn: &rusqlite::Connection, table: &str) -> Result { + Ok(TableShape { + columns: table_columns(conn, table)?, + foreign_keys: table_foreign_keys(conn, table)?, + indexes: table_indexes(conn, table)?, + }) +} + +fn table_columns( + conn: &rusqlite::Connection, + table: &str, +) -> Result> { + let mut stmt = conn + .prepare( + "SELECT name, type, \"notnull\", dflt_value, pk, hidden + FROM pragma_table_xinfo(?1) + ORDER BY cid", ) - .context("Reports unique-index creation failed")?; - return Ok(()); + .with_context(|| format!("Prepare column inspection for {table} failed"))?; + let rows = stmt + .query_map([table], |row| { + let name: String = row.get(0)?; + Ok(( + name, + ColumnShape { + decl_type: row.get(1)?, + not_null: row.get::<_, i64>(2)? == 1, + default_value: row.get(3)?, + primary_key_position: row.get(4)?, + hidden: row.get(5)?, + }, + )) + }) + .with_context(|| format!("Query columns for {table} failed"))?; + + let mut columns = BTreeMap::new(); + for row in rows { + let (name, column) = row.with_context(|| format!("Read column for {table} failed"))?; + columns.insert(name, column); } + Ok(columns) +} - run_structural_migration( - conn, - r" - CREATE TABLE reports_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - post_id INTEGER NOT NULL REFERENCES posts(id) ON DELETE CASCADE, - thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - reason TEXT NOT NULL DEFAULT '', - reporter_hash TEXT NOT NULL, - status TEXT NOT NULL DEFAULT 'open', - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - resolved_at INTEGER, - resolved_by INTEGER REFERENCES admin_users(id) ON DELETE SET NULL +fn table_foreign_keys( + conn: &rusqlite::Connection, + table: &str, +) -> Result> { + let mut stmt = conn + .prepare( + "SELECT \"table\", \"from\", \"to\", on_update, on_delete, match + FROM pragma_foreign_key_list(?1) + ORDER BY id, seq", + ) + .with_context(|| format!("Prepare foreign-key inspection for {table} failed"))?; + let rows = stmt + .query_map([table], |row| { + Ok(ForeignKeyShape { + table_name: row.get(0)?, + from_column: row.get(1)?, + to_column: row.get(2)?, + on_update: row.get(3)?, + on_delete: row.get(4)?, + match_rule: row.get(5)?, + }) + }) + .with_context(|| format!("Query foreign keys for {table} failed"))?; + + let mut foreign_keys = BTreeSet::new(); + for row in rows { + foreign_keys.insert(row.with_context(|| format!("Read foreign key for {table} failed"))?); + } + Ok(foreign_keys) +} + +fn table_indexes(conn: &rusqlite::Connection, table: &str) -> Result> { + let mut stmt = conn + .prepare( + "SELECT name, \"unique\", origin, partial + FROM pragma_index_list(?1) + ORDER BY name", + ) + .with_context(|| format!("Prepare index inspection for {table} failed"))?; + let rows = stmt + .query_map([table], |row| { + Ok(( + row.get::<_, String>(0)?, + row.get::<_, i64>(1)? == 1, + row.get::<_, String>(2)?, + row.get::<_, i64>(3)? == 1, + )) + }) + .with_context(|| format!("Query indexes for {table} failed"))?; + + let mut indexes = BTreeMap::new(); + for row in rows { + let (name, unique, origin, partial) = + row.with_context(|| format!("Read index for {table} failed"))?; + let sql = if name.starts_with("sqlite_") { + None + } else { + schema_sql_for_object(conn, "index", &name)? + }; + indexes.insert( + name.clone(), + IndexShape { + unique, + origin, + partial, + columns: index_columns(conn, &name)?, + where_clause: sql.as_deref().and_then(index_where_clause), + }, ); + } + Ok(indexes) +} - INSERT INTO reports_new - (id, post_id, thread_id, board_id, reason, reporter_hash, - status, created_at, resolved_at, resolved_by) - SELECT r.id, - r.post_id, - p.thread_id, - p.board_id, - r.reason, - r.reporter_hash, - r.status, - r.created_at, - r.resolved_at, - CASE - WHEN r.resolved_by IS NULL THEN NULL - WHEN EXISTS ( - SELECT 1 FROM admin_users au - WHERE au.id = r.resolved_by - ) THEN r.resolved_by - ELSE NULL - END - FROM reports r - JOIN posts p ON p.id = r.post_id; - - DROP TABLE reports; - ALTER TABLE reports_new RENAME TO reports; - - CREATE INDEX idx_reports_status - ON reports(status, created_at DESC); - CREATE UNIQUE INDEX idx_reports_open_unique - ON reports(post_id, reporter_hash) - WHERE status = 'open'; - ", - "Structural migration: rebuild reports table with full foreign keys failed", - "Applied structural migration: reports table integrity hardened", +fn index_columns(conn: &rusqlite::Connection, index: &str) -> Result> { + let mut stmt = conn + .prepare( + "SELECT seqno, cid, name, \"desc\", coll, key + FROM pragma_index_xinfo(?1) + ORDER BY seqno", + ) + .with_context(|| format!("Prepare index-column inspection for {index} failed"))?; + let rows = stmt + .query_map([index], |row| { + Ok(( + row.get::<_, i64>(5)? == 1, + IndexColumnShape { + seqno: row.get(0)?, + cid: row.get(1)?, + name: row.get(2)?, + descending: row.get::<_, i64>(3)? == 1, + collation: row.get(4)?, + }, + )) + }) + .with_context(|| format!("Query index columns for {index} failed"))?; + + let mut columns = Vec::new(); + for row in rows { + let (is_key_column, column) = + row.with_context(|| format!("Read index column for {index} failed"))?; + if is_key_column { + columns.push(column); + } + } + Ok(columns) +} + +fn schema_sql_for_object( + conn: &rusqlite::Connection, + kind: &str, + name: &str, +) -> Result> { + conn.query_row( + "SELECT sql FROM sqlite_schema WHERE type = ?1 AND name = ?2", + rusqlite::params![kind, name], + |row| row.get::<_, Option>(0), ) + .with_context(|| format!("Inspect SQL for schema object {kind}:{name}")) } -fn ensure_posts_ip_hash_nullable(conn: &rusqlite::Connection) -> Result<()> { - if read_column_notnull(conn, "posts", "ip_hash")? { - run_structural_migration( - conn, - "CREATE TABLE posts_new ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Anonymous', - tripcode TEXT, - subject TEXT, - body TEXT NOT NULL, - body_html TEXT NOT NULL, - ip_hash TEXT, - file_path TEXT, - file_name TEXT, - file_size INTEGER, - thumb_path TEXT, - mime_type TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - deletion_token TEXT NOT NULL, - is_op INTEGER NOT NULL DEFAULT 0, - media_type TEXT, - audio_file_path TEXT, - audio_file_name TEXT, - audio_file_size INTEGER, - audio_mime_type TEXT, - edited_at INTEGER, - media_processing_state TEXT NOT NULL DEFAULT '', - media_processing_error TEXT - ); +fn compare_schema_objects(expected: &SchemaShape, actual: &SchemaShape, issues: &mut Vec) { + for (name, expected_object) in &expected.objects { + match actual.objects.get(name) { + Some(actual_object) if actual_object.kind == expected_object.kind => { + if matches!(expected_object.kind.as_str(), "index" | "trigger") + && actual_object.sql != expected_object.sql + { + issues.push(format!("{name} definition differs from baseline")); + } + } + Some(actual_object) => issues.push(format!( + "{name} is a {}, expected {}", + actual_object.kind, expected_object.kind + )), + None => issues.push(format!("missing {} {name}", expected_object.kind)), + } + } - INSERT INTO posts_new ( - id, thread_id, board_id, name, tripcode, subject, body, body_html, - ip_hash, file_path, file_name, file_size, thumb_path, mime_type, - created_at, deletion_token, is_op, media_type, audio_file_path, - audio_file_name, audio_file_size, audio_mime_type, edited_at, - media_processing_state, media_processing_error - ) - SELECT - id, thread_id, board_id, name, tripcode, subject, body, body_html, - ip_hash, file_path, file_name, file_size, thumb_path, mime_type, - created_at, deletion_token, is_op, media_type, audio_file_path, - audio_file_name, audio_file_size, audio_mime_type, edited_at, - '' AS media_processing_state, - NULL AS media_processing_error - FROM posts; - DROP TABLE posts; - ALTER TABLE posts_new RENAME TO posts; - - CREATE INDEX IF NOT EXISTS idx_posts_thread - ON posts(thread_id, created_at ASC); - CREATE INDEX IF NOT EXISTS idx_posts_board - ON posts(board_id, created_at DESC); - CREATE INDEX IF NOT EXISTS idx_posts_thread_id - ON posts(thread_id); - CREATE INDEX IF NOT EXISTS idx_posts_media_processing_state - ON posts(media_processing_state); - CREATE INDEX IF NOT EXISTS idx_posts_ip_hash - ON posts(ip_hash);", - "Structural migration: make posts.ip_hash nullable failed", - "Applied structural migration: posts.ip_hash is now nullable", - )?; + for (name, actual_object) in &actual.objects { + if !expected.objects.contains_key(name) { + issues.push(format!("unexpected {} {name}", actual_object.kind)); + } } +} - Ok(()) +fn compare_tables(expected: &SchemaShape, actual: &SchemaShape, issues: &mut Vec) { + for (table, expected_table) in &expected.tables { + let Some(actual_table) = actual.tables.get(table) else { + continue; + }; + compare_columns( + table, + &expected_table.columns, + &actual_table.columns, + issues, + ); + if actual_table.foreign_keys != expected_table.foreign_keys { + issues.push(format!("{table} foreign keys differ from baseline")); + } + if actual_table.indexes != expected_table.indexes { + issues.push(format!("{table} indexes differ from baseline")); + } + } } -fn backfill_media_type(conn: &rusqlite::Connection) -> Result<()> { - let needs_backfill: i64 = conn - .query_row( - "SELECT COUNT(*) FROM posts WHERE media_type IS NULL AND file_path IS NOT NULL", - [], - |r| r.get(0), - ) - .context("Failed to count posts needing media_type backfill")?; +fn compare_columns( + table: &str, + expected: &BTreeMap, + actual: &BTreeMap, + issues: &mut Vec, +) { + for (column, expected_shape) in expected { + match actual.get(column) { + Some(actual_shape) if actual_shape == expected_shape => {} + Some(_) => issues.push(format!("{table}.{column} definition differs from baseline")), + None => issues.push(format!("missing column {table}.{column}")), + } + } - if needs_backfill > 0 { - conn.execute_batch( - "UPDATE posts - SET media_type = CASE - WHEN file_path LIKE '%.jpg' OR file_path LIKE '%.jpeg' OR - file_path LIKE '%.png' OR file_path LIKE '%.gif' OR - file_path LIKE '%.webp' OR file_path LIKE '%.heic' OR - file_path LIKE '%.heif' THEN 'image' - WHEN file_path LIKE '%.mp4' OR file_path LIKE '%.webm' THEN 'video' - WHEN file_path LIKE '%.mp3' OR file_path LIKE '%.ogg' OR - file_path LIKE '%.flac' OR file_path LIKE '%.wav' OR - file_path LIKE '%.m4a' OR file_path LIKE '%.aac' OR - file_path LIKE '%.opus' THEN 'audio' - WHEN file_path LIKE '%.pdf' THEN 'pdf' - ELSE 'other' - END - WHERE media_type IS NULL AND file_path IS NOT NULL;", - ) - .context("Failed to backfill media_type column")?; + for column in actual.keys() { + if !expected.contains_key(column) { + issues.push(format!("unexpected column {table}.{column}")); + } } +} - Ok(()) +const REQUIRED_TABLE_SQL_FRAGMENTS: [(&str, &str); 2] = [ + ( + "boards", + "CHECK (banner_mode IN ('inherit', 'none', 'override'))", + ), + ( + "boards", + "CHECK (access_mode IN ('public', 'view_password', 'post_password'))", + ), +]; + +fn verify_required_sql_fragments(shape: &SchemaShape, issues: &mut Vec) { + for (table, fragment) in REQUIRED_TABLE_SQL_FRAGMENTS { + let Some(object) = shape.objects.get(table) else { + continue; + }; + let Some(sql) = object.sql.as_deref() else { + issues.push(format!("{table} SQL definition is missing")); + continue; + }; + let normalized_fragment = normalize_schema_sql(fragment); + if !sql.contains(&normalized_fragment) { + issues.push(format!("{table} is missing required constraint {fragment}")); + } + } +} + +fn verify_sqlite_health(conn: &rusqlite::Connection, issues: &mut Vec) { + match conn.query_row("PRAGMA quick_check", [], |row| row.get::<_, String>(0)) { + Ok(result) if result.eq_ignore_ascii_case("ok") => {} + Ok(result) => issues.push(format!("quick_check reported {result}")), + Err(error) => issues.push(format!("quick_check failed: {error}")), + } + + let mut stmt = match conn.prepare("PRAGMA foreign_key_check") { + Ok(stmt) => stmt, + Err(error) => { + issues.push(format!("foreign_key_check failed: {error}")); + return; + } + }; + let rows = match stmt.query_map([], |row| { + let table: String = row.get(0)?; + let rowid: Option = row.get(1)?; + let parent: String = row.get(2)?; + Ok(format!( + "{table} rowid={} parent={parent}", + rowid.map_or_else(|| "unknown".to_owned(), |rowid| rowid.to_string()) + )) + }) { + Ok(rows) => rows, + Err(error) => { + issues.push(format!("foreign_key_check failed: {error}")); + return; + } + }; + + let mut violations = Vec::new(); + for row in rows { + match row { + Ok(message) => violations.push(message), + Err(error) => { + issues.push(format!("foreign_key_check row failed: {error}")); + return; + } + } + } + if !violations.is_empty() { + issues.push(format!( + "foreign_key_check reported {} violation(s): {}", + violations.len(), + violations.join(", ") + )); + } +} + +fn index_where_clause(sql: &str) -> Option { + let lower = sql.to_ascii_lowercase(); + let where_index = lower.find(" where ")?; + sql.get(where_index..).map(str::trim).map(str::to_owned) +} + +fn normalize_schema_sql(sql: &str) -> String { + sql.split_whitespace() + .collect::>() + .join(" ") + .replace('"', "") } #[cfg(test)] mod tests { - use super::install_or_migrate_schema; - use crate::db::migrations::POST_SQUASH_SCHEMA_VERSION; + use super::{ + baseline_schema_version, create_baseline_schema_objects, ensure_board_access_invariants, + install_or_migrate_schema, normalize_database_schema_version, read_schema_version, + verify_database_schema, + }; - fn schema_version(conn: &rusqlite::Connection) -> i64 { + fn schema_version(conn: &rusqlite::Connection) -> String { conn.query_row("SELECT version FROM schema_version", [], |row| row.get(0)) .expect("read schema_version") } @@ -1004,217 +1268,98 @@ mod tests { .expect("inspect sqlite object") } - fn table_has_column(conn: &rusqlite::Connection, table: &str, column: &str) -> bool { + fn column_default(conn: &rusqlite::Connection, table: &str, column: &str) -> Option { conn.query_row( - "SELECT EXISTS ( - SELECT 1 FROM pragma_table_info(?1) - WHERE name = ?2 - )", + "SELECT dflt_value FROM pragma_table_info(?1) WHERE name = ?2", rusqlite::params![table, column], |row| row.get(0), ) - .expect("inspect table column") - } - - fn create_representative_legacy_schema(conn: &rusqlite::Connection, version: i64) { - conn.execute_batch( - r" - CREATE TABLE boards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - short_name TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - nsfw INTEGER NOT NULL DEFAULT 0, - max_threads INTEGER NOT NULL DEFAULT 150, - bump_limit INTEGER NOT NULL DEFAULT 500, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE threads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - subject TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - bumped_at INTEGER NOT NULL DEFAULT (unixepoch()), - locked INTEGER NOT NULL DEFAULT 0, - sticky INTEGER NOT NULL DEFAULT 0, - reply_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Anonymous', - tripcode TEXT, - subject TEXT, - body TEXT NOT NULL, - body_html TEXT NOT NULL, - ip_hash TEXT NOT NULL, - file_path TEXT, - file_name TEXT, - file_size INTEGER, - thumb_path TEXT, - mime_type TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - deletion_token TEXT NOT NULL, - is_op INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE site_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - CREATE TABLE schema_version ( - version INTEGER NOT NULL DEFAULT 0, - UNIQUE(version) - );", - ) - .expect("create representative legacy schema"); - - conn.execute( - "INSERT INTO schema_version (version) VALUES (?1)", - [version], - ) - .expect("insert legacy schema version"); + .expect("read column default") } - fn insert_legacy_thread_with_post(conn: &rusqlite::Connection) { + fn rebuild_boards_with_historical_v41_shape(conn: &rusqlite::Connection) { conn.execute_batch( r" - INSERT INTO site_settings (key, value) VALUES ('collapse_greentext', 'true'); - INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random'); - INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'legacy subject'); - INSERT INTO posts ( - id, thread_id, board_id, body, body_html, ip_hash, - file_path, file_name, mime_type, deletion_token, is_op - ) - VALUES ( - 100, 10, 1, 'legacy searchable body', '

legacy searchable body

', - 'old-ip', 'uploads/a.png', 'a.png', 'image/png', 'tok', 1 - );", - ) - .expect("insert representative legacy data"); - } + PRAGMA foreign_keys = OFF; + BEGIN IMMEDIATE; - fn insert_legacy_duplicate_ops(conn: &rusqlite::Connection) { - conn.execute_batch( - r" - INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random'); - INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'legacy subject'); - INSERT INTO posts (id, thread_id, board_id, body, body_html, ip_hash, deletion_token, is_op) - VALUES (100, 10, 1, 'first op', 'first op', 'ip-a', 'tok-a', 1); - INSERT INTO posts (id, thread_id, board_id, body, body_html, ip_hash, deletion_token, is_op) - VALUES (101, 10, 1, 'duplicate op', 'duplicate op', 'ip-b', 'tok-b', 1);", - ) - .expect("insert invalid legacy data"); - } - - fn create_partial_post_squash_schema(conn: &rusqlite::Connection) { - conn.execute_batch( - r" - CREATE TABLE boards ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - display_order INTEGER NOT NULL DEFAULT 0, - short_name TEXT NOT NULL UNIQUE, - name TEXT NOT NULL, - description TEXT NOT NULL DEFAULT '', - nsfw INTEGER NOT NULL DEFAULT 0, - max_threads INTEGER NOT NULL DEFAULT 150, + CREATE TABLE boards_legacy ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + nsfw INTEGER NOT NULL DEFAULT 0, + max_threads INTEGER NOT NULL DEFAULT 150, + bump_limit INTEGER NOT NULL DEFAULT 500, + created_at INTEGER NOT NULL DEFAULT (unixepoch()), + display_order INTEGER NOT NULL DEFAULT 0, max_archived_threads INTEGER NOT NULL DEFAULT 150, - bump_limit INTEGER NOT NULL DEFAULT 500, - allow_video INTEGER NOT NULL DEFAULT 1, - allow_tripcodes INTEGER NOT NULL DEFAULT 1, - allow_images INTEGER NOT NULL DEFAULT 1, - allow_audio INTEGER NOT NULL DEFAULT 0, - allow_any_files INTEGER NOT NULL DEFAULT 0, - edit_window_secs INTEGER NOT NULL DEFAULT 0, - allow_editing INTEGER NOT NULL DEFAULT 1, - allow_video_embeds INTEGER NOT NULL DEFAULT 1, - allow_captcha INTEGER NOT NULL DEFAULT 0, - show_poster_ids INTEGER NOT NULL DEFAULT 1, - collapse_greentext INTEGER NOT NULL DEFAULT 0, - post_cooldown_secs INTEGER NOT NULL DEFAULT 0, - default_theme TEXT NOT NULL DEFAULT '', - banner_mode TEXT NOT NULL DEFAULT 'inherit' - CHECK (banner_mode IN ('inherit', 'none', 'override')), - access_mode TEXT NOT NULL DEFAULT 'public' - CHECK (access_mode IN ('public', 'view_password', 'post_password')), + allow_video INTEGER NOT NULL DEFAULT 1, + allow_tripcodes INTEGER NOT NULL DEFAULT 1, + allow_images INTEGER NOT NULL DEFAULT 1, + allow_audio INTEGER NOT NULL DEFAULT 0, + max_image_size INTEGER NOT NULL DEFAULT 8388608, + max_video_size INTEGER NOT NULL DEFAULT 52428800, + max_audio_size INTEGER NOT NULL DEFAULT 157286400, + max_pdf_size INTEGER NOT NULL DEFAULT 157286400, + allow_pdf INTEGER NOT NULL DEFAULT 0, + allow_any_files INTEGER NOT NULL DEFAULT 0, + edit_window_secs INTEGER NOT NULL DEFAULT 0, + allow_editing INTEGER NOT NULL DEFAULT 0, + allow_self_delete INTEGER NOT NULL DEFAULT 0, + allow_archive INTEGER NOT NULL DEFAULT 1, + allow_video_embeds INTEGER NOT NULL DEFAULT 0, + allow_captcha INTEGER NOT NULL DEFAULT 0, + show_poster_ids INTEGER NOT NULL DEFAULT 0, + collapse_greentext INTEGER NOT NULL DEFAULT 0, + post_cooldown_secs INTEGER NOT NULL DEFAULT 0, + default_theme TEXT NOT NULL DEFAULT '', + access_mode TEXT NOT NULL DEFAULT 'public', access_password_hash TEXT NOT NULL DEFAULT '', - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE threads ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - subject TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - bumped_at INTEGER NOT NULL DEFAULT (unixepoch()), - locked INTEGER NOT NULL DEFAULT 0, - sticky INTEGER NOT NULL DEFAULT 0, - archived INTEGER NOT NULL DEFAULT 0, - reply_count INTEGER NOT NULL DEFAULT 0 - ); - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Anonymous', - tripcode TEXT, - subject TEXT, - body TEXT NOT NULL, - body_html TEXT NOT NULL, - ip_hash TEXT, - file_path TEXT, - file_name TEXT, - file_size INTEGER, - thumb_path TEXT, - mime_type TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - deletion_token TEXT NOT NULL, - is_op INTEGER NOT NULL DEFAULT 0, - media_type TEXT, - audio_file_path TEXT, - audio_file_name TEXT, - audio_file_size INTEGER, - audio_mime_type TEXT, - edited_at INTEGER, - media_processing_state TEXT NOT NULL DEFAULT '', - media_processing_error TEXT + banner_mode TEXT NOT NULL DEFAULT 'inherit' ); - CREATE TABLE site_settings ( - key TEXT PRIMARY KEY, - value TEXT NOT NULL - ); - CREATE TABLE banner_assets ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - scope_type TEXT NOT NULL, - board_id INTEGER REFERENCES boards(id) ON DELETE CASCADE, - storage_key TEXT NOT NULL UNIQUE, - width INTEGER NOT NULL, - height INTEGER NOT NULL, - file_size INTEGER NOT NULL, - enabled INTEGER NOT NULL DEFAULT 1, - sort_order INTEGER NOT NULL DEFAULT 0, - target_type TEXT NOT NULL DEFAULT 'none', - target_value TEXT NOT NULL DEFAULT '', - show_on_index INTEGER NOT NULL DEFAULT 1, - show_on_catalog INTEGER NOT NULL DEFAULT 1, - created_at INTEGER NOT NULL DEFAULT (unixepoch()) - ); - CREATE TABLE schema_version ( - version INTEGER NOT NULL DEFAULT 0, - UNIQUE(version) - ); - INSERT INTO schema_version (version) VALUES (1); - INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random'); + + INSERT INTO boards_legacy ( + id, short_name, name, description, nsfw, max_threads, + bump_limit, created_at, display_order, max_archived_threads, + allow_video, allow_tripcodes, allow_images, allow_audio, + max_image_size, max_video_size, max_audio_size, max_pdf_size, + allow_pdf, allow_any_files, edit_window_secs, allow_editing, + allow_self_delete, allow_archive, allow_video_embeds, + allow_captcha, show_poster_ids, collapse_greentext, + post_cooldown_secs, default_theme, access_mode, + access_password_hash, banner_mode + ) + SELECT + id, short_name, name, description, nsfw, max_threads, + bump_limit, created_at, display_order, max_archived_threads, + allow_video, allow_tripcodes, allow_images, allow_audio, + max_image_size, max_video_size, max_audio_size, max_pdf_size, + allow_pdf, allow_any_files, edit_window_secs, allow_editing, + allow_self_delete, allow_archive, allow_video_embeds, + allow_captcha, show_poster_ids, collapse_greentext, + post_cooldown_secs, default_theme, access_mode, + access_password_hash, banner_mode + FROM boards; + + DROP TABLE boards; + ALTER TABLE boards_legacy RENAME TO boards; + + COMMIT; + PRAGMA foreign_keys = ON; ", ) - .expect("create partial post-squash schema"); + .expect("rebuild boards into historical v41 shape"); + ensure_board_access_invariants(conn).expect("restore board access triggers"); } #[test] - fn fresh_database_installs_canonical_post_squash_baseline() { + fn fresh_database_installs_130_baseline_directly() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); install_or_migrate_schema(&conn).expect("install schema"); - assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION); + assert_eq!(schema_version(&conn), "1.3.0"); + assert_eq!(baseline_schema_version(), "1.3.0"); assert!(object_exists(&conn, "table", "boards")); assert!(object_exists(&conn, "table", "posts")); assert!(object_exists(&conn, "table", "posts_fts")); @@ -1227,200 +1372,157 @@ mod tests { assert!(object_exists(&conn, "trigger", "posts_ai")); assert!(object_exists(&conn, "trigger", "posts_board_match_insert")); assert!(object_exists(&conn, "trigger", "boards_access_mode_insert")); + verify_database_schema(&conn).expect("fresh schema verifies"); } #[test] - fn legacy_posts_ip_hash_rebuild_keeps_posts_triggers_and_indexes() { + fn existing_current_schema_is_adopted_as_130_without_data_loss() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); + create_baseline_schema_objects(&conn).expect("create baseline objects"); conn.execute_batch( - r" - CREATE TABLE posts ( - id INTEGER PRIMARY KEY AUTOINCREMENT, - thread_id INTEGER NOT NULL REFERENCES threads(id) ON DELETE CASCADE, - board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, - name TEXT NOT NULL DEFAULT 'Anonymous', - tripcode TEXT, - subject TEXT, - body TEXT NOT NULL, - body_html TEXT NOT NULL, - ip_hash TEXT NOT NULL, - file_path TEXT, - file_name TEXT, - file_size INTEGER, - thumb_path TEXT, - mime_type TEXT, - created_at INTEGER NOT NULL DEFAULT (unixepoch()), - deletion_token TEXT NOT NULL, - is_op INTEGER NOT NULL DEFAULT 0, - media_type TEXT, - audio_file_path TEXT, - audio_file_name TEXT, - audio_file_size INTEGER, - audio_mime_type TEXT, - edited_at INTEGER, - media_processing_state TEXT NOT NULL DEFAULT '', - media_processing_error TEXT - ); - CREATE TABLE schema_version ( + "CREATE TABLE schema_version ( version INTEGER NOT NULL DEFAULT 0, UNIQUE(version) ); INSERT INTO schema_version (version) VALUES (41); - ", + INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random'); + INSERT INTO threads (id, board_id, subject) VALUES (10, 1, 'subject'); + INSERT INTO posts (id, thread_id, board_id, body, body_html, deletion_token, is_op) + VALUES (100, 10, 1, 'preserved body', '

preserved body

', 'tok', 1);", ) - .expect("create legacy posts table"); + .expect("seed adoptable current schema"); - install_or_migrate_schema(&conn).expect("install schema"); + install_or_migrate_schema(&conn).expect("adopt current schema"); - let posts_ai_exists: bool = conn - .query_row( - "SELECT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'trigger' AND name = 'posts_ai' - )", - [], - |row| row.get(0), - ) - .expect("check posts_ai trigger"); - let board_match_trigger_exists: bool = conn - .query_row( - "SELECT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'trigger' AND name = 'posts_board_match_insert' - )", - [], - |row| row.get(0), - ) - .expect("check board-match trigger"); - let one_op_index_exists: bool = conn - .query_row( - "SELECT EXISTS ( - SELECT 1 FROM sqlite_master - WHERE type = 'index' AND name = 'idx_posts_one_op_per_thread' - )", - [], - |row| row.get(0), - ) - .expect("check one-op index"); - - assert!(posts_ai_exists); - assert!(board_match_trigger_exists); - assert!(one_op_index_exists); + assert_eq!(schema_version(&conn), "1.3.0"); + let body: String = conn + .query_row("SELECT body FROM posts WHERE id = 100", [], |row| { + row.get(0) + }) + .expect("read preserved post"); + assert_eq!(body, "preserved body"); + } + #[test] + fn historical_v41_schema_drift_repairs_to_130_baseline_without_data_loss() { + let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); + create_baseline_schema_objects(&conn).expect("create baseline objects"); conn.execute( - "INSERT INTO boards (short_name, name) VALUES ('test', 'Test')", + "INSERT INTO boards ( + id, short_name, name, allow_editing, allow_self_delete, + allow_video_embeds, show_poster_ids, banner_mode, access_mode + ) + VALUES (1, 'b', 'Random', 0, 1, 0, 1, 'override', 'public')", [], ) - .expect("insert board"); - let board_id = conn.last_insert_rowid(); - conn.execute( - "INSERT INTO threads (board_id, subject) VALUES (?1, 'subject')", - [board_id], - ) - .expect("insert thread"); - let thread_id = conn.last_insert_rowid(); - conn.execute( - "INSERT INTO posts (thread_id, board_id, body, body_html, deletion_token, is_op) - VALUES (?1, ?2, 'searchable body', 'searchable body', 'token', 1)", - (thread_id, board_id), + .expect("insert board before legacy reshape"); + rebuild_boards_with_historical_v41_shape(&conn); + conn.execute_batch( + "CREATE INDEX idx_themes_enabled_sort + ON themes(enabled, sort_order, slug); + CREATE TABLE schema_version ( + version INTEGER NOT NULL DEFAULT 0, + UNIQUE(version) + ); + INSERT INTO schema_version (version) VALUES (41);", ) - .expect("insert post"); + .expect("create historical v41 drift markers"); - let fts_count: i64 = conn - .query_row("SELECT COUNT(*) FROM posts_fts", [], |row| row.get(0)) - .expect("read posts_fts count"); - assert_eq!(fts_count, 1); - } - - #[test] - fn legacy_database_upgrades_to_post_squash_baseline_and_preserves_data() { - let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); - create_representative_legacy_schema(&conn, 4); - insert_legacy_thread_with_post(&conn); + let error = + verify_database_schema(&conn).expect_err("legacy drift should fail strict check"); + let message = error.to_string(); + assert!( + message.contains("idx_themes_enabled_sort") + && message.contains("boards.allow_editing definition differs") + && message.contains("missing required constraint CHECK (access_mode"), + "unexpected strict-check error: {error:#}" + ); - install_or_migrate_schema(&conn).expect("upgrade legacy schema"); + install_or_migrate_schema(&conn).expect("repair historical v41 schema drift"); - assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION); - assert!(table_has_column(&conn, "boards", "banner_mode")); - assert!(table_has_column(&conn, "boards", "access_password_hash")); - assert!(table_has_column(&conn, "threads", "archived")); - assert!(table_has_column(&conn, "posts", "media_processing_state")); - assert!(object_exists(&conn, "table", "banner_assets")); - assert!(object_exists(&conn, "table", "posts_fts")); - assert!(object_exists( - &conn, - "index", - "idx_posts_media_processing_state" - )); - assert!(object_exists(&conn, "trigger", "posts_ai")); - assert!(object_exists(&conn, "trigger", "posts_board_match_insert")); - - let post: (String, Option, String) = conn - .query_row( - "SELECT body, media_type, ip_hash FROM posts WHERE id = 100", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .expect("read preserved post"); + assert_eq!(schema_version(&conn), "1.3.0"); + assert!(!object_exists(&conn, "index", "idx_themes_enabled_sort")); + verify_database_schema(&conn).expect("repaired schema verifies"); assert_eq!( - post, - ( - "legacy searchable body".to_owned(), - Some("image".to_owned()), - "old-ip".to_owned() - ) + column_default(&conn, "boards", "allow_editing").as_deref(), + Some("1") + ); + assert_eq!( + column_default(&conn, "boards", "allow_self_delete").as_deref(), + Some("1") ); - let fts_count: i64 = conn - .query_row("SELECT COUNT(*) FROM posts_fts", [], |row| row.get(0)) - .expect("read posts_fts count"); - assert_eq!(fts_count, 1); - - let collapse_greentext: i64 = conn + let board_flags: (i64, i64, i64, i64, String, String) = conn .query_row( - "SELECT collapse_greentext FROM boards WHERE id = 1", + "SELECT allow_editing, allow_self_delete, allow_video_embeds, + show_poster_ids, banner_mode, access_mode + FROM boards WHERE id = 1", [], - |row| row.get(0), + |row| { + Ok(( + row.get(0)?, + row.get(1)?, + row.get(2)?, + row.get(3)?, + row.get(4)?, + row.get(5)?, + )) + }, ) - .expect("read collapse_greentext"); - assert_eq!(collapse_greentext, 1); + .expect("read preserved board flags"); + assert_eq!( + board_flags, + (0, 1, 0, 1, "override".to_owned(), "public".to_owned()) + ); } #[test] - fn historical_v1_database_is_still_detected_as_legacy() { + fn partial_schema_fails_closed_without_creating_missing_tables() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); - create_representative_legacy_schema(&conn, POST_SQUASH_SCHEMA_VERSION); - insert_legacy_thread_with_post(&conn); + conn.execute_batch( + "CREATE TABLE boards ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + short_name TEXT NOT NULL UNIQUE, + name TEXT NOT NULL + ); + CREATE TABLE schema_version ( + version INTEGER NOT NULL DEFAULT 0, + UNIQUE(version) + ); + INSERT INTO schema_version (version) VALUES (36); + INSERT INTO boards (id, short_name, name) VALUES (1, 'b', 'Random');", + ) + .expect("create partial schema"); - install_or_migrate_schema(&conn).expect("upgrade historical v1 schema"); + let error = install_or_migrate_schema(&conn).expect_err("partial schema should fail"); + assert!( + error + .to_string() + .contains("does not match RustChan 1.3.0 baseline"), + "unexpected error: {error:#}" + ); - assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION); - assert!(table_has_column(&conn, "boards", "banner_mode")); - assert!(table_has_column(&conn, "posts", "media_processing_state")); - assert!(object_exists(&conn, "table", "banner_assets")); - assert!(object_exists(&conn, "trigger", "posts_ai")); + assert!(!object_exists(&conn, "table", "posts")); + assert_eq!( + read_schema_version(&conn).expect("read version"), + Some("36".to_owned()) + ); } #[test] - fn post_squash_database_repairs_missing_additive_board_columns() { + fn unknown_schema_object_fails_closed() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); - create_partial_post_squash_schema(&conn); - - install_or_migrate_schema(&conn).expect("repair partial post-squash schema"); - - assert_eq!(schema_version(&conn), POST_SQUASH_SCHEMA_VERSION); - assert!(table_has_column(&conn, "boards", "allow_self_delete")); - assert!(table_has_column(&conn, "boards", "allow_archive")); - assert!(table_has_column(&conn, "boards", "allow_pdf")); + install_or_migrate_schema(&conn).expect("install schema"); + conn.execute("CREATE TABLE operator_notes (body TEXT)", []) + .expect("create unexpected table"); - let flags: (i64, i64, i64) = conn - .query_row( - "SELECT allow_self_delete, allow_archive, allow_pdf FROM boards WHERE id = 1", - [], - |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)), - ) - .expect("read repaired board flags"); - assert_eq!(flags, (0, 1, 0)); + let error = normalize_database_schema_version(&conn).expect_err("unknown table fails"); + assert!( + error + .to_string() + .contains("unexpected table operator_notes"), + "unexpected error: {error:#}" + ); } #[test] @@ -1463,17 +1565,29 @@ mod tests { } #[test] - fn failed_legacy_upgrade_does_not_stamp_post_squash_version() { + fn invalid_structural_change_does_not_stamp_130() { let conn = rusqlite::Connection::open_in_memory().expect("open in-memory sqlite"); - create_representative_legacy_schema(&conn, 36); - insert_legacy_duplicate_ops(&conn); + create_baseline_schema_objects(&conn).expect("create baseline objects"); + conn.execute_batch( + "CREATE TABLE schema_version ( + version INTEGER NOT NULL DEFAULT 0, + UNIQUE(version) + ); + INSERT INTO schema_version (version) VALUES (41); + DROP TRIGGER posts_board_match_insert;", + ) + .expect("make schema invalid"); - let error = install_or_migrate_schema(&conn).expect_err("legacy upgrade should fail"); + let error = install_or_migrate_schema(&conn).expect_err("invalid schema should fail"); assert!( - error.to_string().contains("Schema index creation failed") - || error.to_string().contains("Post invariant creation failed"), + error + .to_string() + .contains("missing trigger posts_board_match_insert"), "unexpected error: {error:#}" ); - assert_eq!(schema_version(&conn), 36); + assert_eq!( + read_schema_version(&conn).expect("read version"), + Some("41".to_owned()) + ); } } diff --git a/src/db/setup.rs b/src/db/setup.rs new file mode 100644 index 00000000..477cd910 --- /dev/null +++ b/src/db/setup.rs @@ -0,0 +1,211 @@ +use anyhow::{Context as _, Result}; +use rusqlite::{params, OptionalExtension as _}; + +pub const SETUP_COMPLETED_AT_KEY: &str = "setup_completed_at"; +pub const SETUP_REOPENED_AT_KEY: &str = "setup_reopened_at"; +pub const SETUP_REOPENED_BY_KEY: &str = "setup_reopened_by"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupAccess { + Fresh, + Reopened, + Initialized, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct SetupState { + pub access: SetupAccess, + pub admin_count: i64, + pub board_count: i64, + pub completed: bool, + pub reopened: bool, +} + +impl SetupState { + #[must_use] + pub const fn is_available(self) -> bool { + matches!(self.access, SetupAccess::Fresh | SetupAccess::Reopened) + } + + #[must_use] + pub const fn requires_admin_auth(self) -> bool { + matches!(self.access, SetupAccess::Reopened) || self.admin_count > 0 + } +} + +/// # Errors +/// Returns an error if setup state cannot be loaded. +pub fn setup_state(conn: &rusqlite::Connection) -> Result { + let completed = setup_flag(conn, SETUP_COMPLETED_AT_KEY)?; + let reopened = setup_flag(conn, SETUP_REOPENED_AT_KEY)?; + let admin_count = table_count(conn, "admin_users")?; + let board_count = table_count(conn, "boards")?; + let access = if reopened { + SetupAccess::Reopened + } else if completed || admin_count > 0 { + SetupAccess::Initialized + } else { + SetupAccess::Fresh + }; + Ok(SetupState { + access, + admin_count, + board_count, + completed, + reopened, + }) +} + +fn setup_flag(conn: &rusqlite::Connection, key: &str) -> Result { + let value = super::get_site_setting(conn, key)?; + Ok(value + .as_deref() + .is_some_and(|value| !value.trim().is_empty())) +} + +fn table_count(conn: &rusqlite::Connection, table: &str) -> Result { + let sql = match table { + "admin_users" => "SELECT COUNT(*) FROM admin_users", + "boards" => "SELECT COUNT(*) FROM boards", + _ => anyhow::bail!("unsupported setup count table"), + }; + conn.query_row(sql, [], |row| row.get(0)) + .context("count setup state table") +} + +/// # Errors +/// Returns an error if setup is unavailable. +pub fn ensure_setup_available(conn: &rusqlite::Connection) -> Result { + let state = setup_state(conn)?; + if state.is_available() { + Ok(state) + } else { + anyhow::bail!("setup wizard is not available on initialized instances") + } +} + +/// # Errors +/// Returns an error if the reopen marker cannot be persisted. +pub fn reopen_setup(conn: &rusqlite::Connection, admin_id: i64) -> Result<()> { + let now = chrono::Utc::now().timestamp().to_string(); + super::set_site_setting(conn, SETUP_REOPENED_AT_KEY, &now)?; + super::set_site_setting(conn, SETUP_REOPENED_BY_KEY, &admin_id.to_string())?; + Ok(()) +} + +/// # Errors +/// Returns an error if the reopen marker cannot be cleared. +pub fn close_reopened_setup(conn: &rusqlite::Connection) -> Result<()> { + conn.execute( + "DELETE FROM site_settings WHERE key IN (?1, ?2)", + params![SETUP_REOPENED_AT_KEY, SETUP_REOPENED_BY_KEY], + ) + .context("clear setup reopen marker")?; + Ok(()) +} + +/// # Errors +/// Returns an error if the setup completion marker cannot be persisted. +pub fn mark_setup_complete(conn: &rusqlite::Connection) -> Result<()> { + let now = chrono::Utc::now().timestamp().to_string(); + conn.execute( + "INSERT INTO site_settings (key, value) VALUES (?1, ?2) + ON CONFLICT(key) DO UPDATE SET value = excluded.value", + params![SETUP_COMPLETED_AT_KEY, now], + ) + .context("write setup completion marker")?; + close_reopened_setup(conn)?; + Ok(()) +} + +/// # Errors +/// Returns an error if the admin count query fails. +pub fn admin_count(conn: &rusqlite::Connection) -> Result { + table_count(conn, "admin_users") +} + +/// # Errors +/// Returns an error if the board lookup fails. +pub fn board_slug_exists(conn: &rusqlite::Connection, slug: &str) -> Result { + conn.query_row( + "SELECT 1 FROM boards WHERE short_name = ?1 LIMIT 1", + params![slug], + |_row| Ok(()), + ) + .optional() + .context("check board slug conflict") + .map(|row| row.is_some()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn fresh_database_allows_setup() { + let pool = crate::db::init_test_pool().expect("pool"); + let conn = pool.get().expect("conn"); + + let state = setup_state(&conn).expect("state"); + + assert_eq!(state.access, SetupAccess::Fresh); + assert!(state.is_available()); + assert!(!state.requires_admin_auth()); + } + + #[test] + fn admin_without_marker_blocks_setup_as_initialized() { + let pool = crate::db::init_test_pool().expect("pool"); + let conn = pool.get().expect("conn"); + crate::db::create_admin(&conn, "admin", "hash").expect("admin"); + + let state = setup_state(&conn).expect("state"); + + assert_eq!(state.access, SetupAccess::Initialized); + assert!(!state.is_available()); + assert!(state.requires_admin_auth()); + } + + #[test] + fn admin_reopen_makes_setup_available_but_admin_authenticated() { + let pool = crate::db::init_test_pool().expect("pool"); + let conn = pool.get().expect("conn"); + crate::db::create_admin(&conn, "admin", "hash").expect("admin"); + reopen_setup(&conn, 1).expect("reopen"); + + let state = setup_state(&conn).expect("state"); + + assert_eq!(state.access, SetupAccess::Reopened); + assert!(state.is_available()); + assert!(state.requires_admin_auth()); + } + + #[test] + fn completion_marker_blocks_setup_after_reopen_is_cleared() { + let pool = crate::db::init_test_pool().expect("pool"); + let conn = pool.get().expect("conn"); + reopen_setup(&conn, 1).expect("reopen"); + mark_setup_complete(&conn).expect("complete"); + + let state = setup_state(&conn).expect("state"); + + assert_eq!(state.access, SetupAccess::Initialized); + assert!(state.completed); + assert!(!state.reopened); + } + + #[test] + fn close_reopened_setup_relocks_completed_instance_without_clearing_completion() { + let pool = crate::db::init_test_pool().expect("pool"); + let conn = pool.get().expect("conn"); + mark_setup_complete(&conn).expect("complete"); + reopen_setup(&conn, 1).expect("reopen"); + + close_reopened_setup(&conn).expect("close"); + let state = setup_state(&conn).expect("state"); + + assert_eq!(state.access, SetupAccess::Initialized); + assert!(state.completed); + assert!(!state.reopened); + } +} diff --git a/src/detect.rs b/src/detect.rs index e0e9033f..411ed8e6 100644 --- a/src/detect.rs +++ b/src/detect.rs @@ -463,6 +463,7 @@ async fn run_arti( target: "rustchan::detect", cache_dir = %crate::config::runtime_tor_cache_dir().display(), state_dir = %crate::config::runtime_tor_state_dir().display(), + key_dir = %crate::config::runtime_tor_hidden_service_keys_dir().display(), "Tor: bootstrapping — first run downloads ~2 MB of directory data" ); @@ -473,7 +474,7 @@ async fn run_arti( std::time::Duration::from_secs(crate::config::CONFIG.tor_bootstrap_timeout_secs); // KEEP ALIVE: dropping the final Tor client handle closes all Tor circuits // and kills the onion service. - let tor_client: Arc> = Arc::new( + let tor_client: Arc> = tokio::time::timeout(bootstrap_timeout, TorClient::create_bootstrapped(config)) .await .map_err(|_error| { @@ -483,18 +484,23 @@ async fn run_arti( crate::config::CONFIG.tor_bootstrap_timeout_secs, ) })? - .map_err(|e| format!("Tor bootstrap failed: {e}"))?, - ); + .map_err(|e| format!("Tor bootstrap failed: {e}"))?; tracing::info!(target: "rustchan::detect", "Tor: connected to the Tor network"); - // Security hardening options available in OnionServiceConfigBuilder (Arti 0.41): + // Security hardening options available in OnionServiceConfigBuilder (Arti 0.43): // .pow_resistance(...) — proof-of-work DoS resistance // .rate_limit_num_intro_points — cap introduction point abuse // Currently left at defaults. Consider exposing these in settings.toml (F-18). // // "rustchan") so operators running multiple instances with a shared // runtime/tor/state/ directory can assign distinct names and avoid key collisions. + tracing::info!( + target: "rustchan::detect", + nickname = %crate::config::CONFIG.tor_service_nickname, + key_dir = %crate::config::runtime_tor_hidden_service_keys_dir().display(), + "Tor: launching onion service" + ); let svc_config = OnionServiceConfigBuilder::default() .nickname(crate::config::CONFIG.tor_service_nickname.parse()?) .build()?; @@ -504,7 +510,7 @@ async fn run_arti( .launch_onion_service(svc_config)? .ok_or("launch_onion_service returned None — unexpected with code-only config")?; - // F-03: onion_address() can return None during early bringup in Arti 0.41; + // F-03: onion_address() can return None during early bringup in Arti; // key material is not guaranteed to be readable synchronously at launch time. // Retry up to 10 times at 500 ms intervals (5 s total) before failing. let hsid = { @@ -520,9 +526,6 @@ async fn run_arti( found.ok_or("onion_address() still None after 5 s — service key unavailable")? }; let onion_name = hsid_to_onion_address(hsid); - publish_onion_address(&onion_name, &onion_address).await; - - let mut stream_requests = handle_rend_requests(rend_requests); // F-05: Cap concurrent proxy tasks to prevent file-descriptor exhaustion // under a connection flood. Excess connections are dropped (Arti sends @@ -537,34 +540,20 @@ async fn run_arti( .into_boxed_str(), ); + tracing::info!( + target: "rustchan::detect", + local_target = %local_addr.as_ref(), + max_streams, + "Tor: onion service ready and forwarding to local HTTP server" + ); + publish_onion_address(&onion_name, &onion_address).await; + + let mut stream_requests = handle_rend_requests(rend_requests); + while let Some(stream_req) = stream_requests.next().await { match std::sync::Arc::clone(&sem).try_acquire_owned() { Ok(permit) => { - let addr = Arc::clone(&local_addr); - tokio::spawn(async move { - let _permit = permit; // released on drop - // unreachable) from normal stream closure (client disconnect, - // EOF). Infrastructure errors go to ERROR; everything else - // stays at DEBUG so the log isn't flooded by normal traffic. - if let Err(e) = proxy_tor_stream(stream_req, &addr).await { - let msg = e.to_string(); - if msg.contains("local TCP connect failed") - || msg.contains("timed out connecting to local HTTP server") - { - tracing::error!( - target: "rustchan::detect", - error = %e, - "Tor: cannot reach local HTTP server — is axum running?" - ); - } else { - tracing::debug!( - target: "rustchan::detect", - error = %e, - "Tor: stream closed" - ); - } - } - }); + spawn_tor_stream_proxy(stream_req, permit, Arc::clone(&local_addr)); } Err(_) => { tracing::warn!( @@ -622,13 +611,39 @@ async fn publish_onion_address(onion_name: &str, onion_address: &RwLock, +) { + tokio::spawn(async move { + let _permit = permit; // released on drop + if let Err(e) = proxy_tor_stream(stream_req, &local_addr).await { + let msg = e.to_string(); + if msg.contains("local TCP connect failed") + || msg.contains("timed out connecting to local HTTP server") + { + tracing::error!( + target: "rustchan::detect", + error = %e, + "Tor: cannot reach local HTTP server — is axum running?" + ); + } else { + tracing::debug!( + target: "rustchan::detect", + error = %e, + "Tor: stream closed" + ); + } + } + }); +} + // ─── Connection proxy ───────────────────────────────────────────────────────── async fn proxy_tor_stream( @@ -672,7 +687,7 @@ async fn proxy_tor_stream( /// Encode an [`HsId`] (Ed25519 public key) as a v3 `.onion` address string. /// -/// [`HsId`] does not implement `std::fmt::Display` in arti-client 0.41. +/// [`HsId`] does not implement `std::fmt::Display` in arti-client. /// Encoded manually using `HsId: AsRef<[u8; 32]>`. /// /// Format: `base32( pubkey || sha3_256(".onion checksum" || pubkey || version)[..2] || version )` diff --git a/src/handlers/admin/auth.rs b/src/handlers/admin/auth.rs index 039c6421..a4cc816d 100644 --- a/src/handlers/admin/auth.rs +++ b/src/handlers/admin/auth.rs @@ -428,6 +428,9 @@ mod tests { const TEST_CSRF_COOKIE: &str = "csrf123"; const TEST_ADMIN_ORIGIN: &str = "http://localhost"; + const TEST_ONION_HOST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion"; + const TEST_ONION_ORIGIN: &str = + "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion"; fn signed_admin_csrf() -> String { make_scoped_csrf_form_token( @@ -835,6 +838,56 @@ mod tests { assert!(location.starts_with("/admin/panel?bootstrap=")); } + #[tokio::test] + async fn admin_login_over_onion_http_sets_insecure_session_cookie() { + let state = crate::test_support::app_state(); + { + let conn = state.db.get().expect("db connection"); + let password_hash = + crate::utils::crypto::hash_password("hunter2").expect("hash password"); + crate::db::create_admin(&conn, "admin", &password_hash).expect("create admin"); + crate::db::create_board(&conn, "test", "Test", "", false).expect("create board"); + } + + let router = Router::new() + .route("/admin/login", post(super::admin_login)) + .with_state(state); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/admin/login") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::HOST, TEST_ONION_HOST) + .header(header::ORIGIN, TEST_ONION_ORIGIN) + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(format!( + "username=admin&password=hunter2&_csrf={}", + signed_admin_csrf() + ))) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let session_cookie = response + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .find(|value| value.contains(super::super::SESSION_COOKIE)) + .expect("session cookie"); + assert!(!session_cookie.contains("; Secure")); + let location = response + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()) + .expect("location header"); + assert!(location.starts_with("/admin/panel?bootstrap=")); + } + #[tokio::test] async fn admin_login_rejects_raw_readable_csrf_cookie_without_signed_form_token() { let state = crate::test_support::app_state(); diff --git a/src/handlers/admin/backup.rs b/src/handlers/admin/backup.rs index 552a3587..9afc9946 100644 --- a/src/handlers/admin/backup.rs +++ b/src/handlers/admin/backup.rs @@ -60,9 +60,9 @@ mod http; mod listing; mod restore_board; mod restore_full; +mod saved_backup; mod types; -mod v4; -pub(crate) use v4::BackupStorageMode; +pub(crate) use saved_backup::BackupStorageMode; use common::{ copy_limited, create_staging_dir, extract_uploads_to_dir, log_backup_phase, @@ -529,6 +529,12 @@ pub fn temp_board_download_dir() -> PathBuf { // ─── Board-level backup / restore ───────────────────────────────────────────── +#[derive(Deserialize)] +pub struct BoardBackupDownloadQuery { + #[serde(rename = "_csrf")] + csrf: Option, +} + /// Stream a board-level backup zip: manifest JSON + that board's upload files. /// /// MEM-FIX: Same approach as `admin_backup` — build zip into a `NamedTempFile` on @@ -536,8 +542,11 @@ pub fn temp_board_download_dir() -> PathBuf { pub async fn board_backup( State(state): State, jar: CookieJar, + Query(query): Query, axum::extract::Path(board_short): axum::extract::Path, ) -> Result { + check_admin_csrf_jar(&jar, query.csrf.as_deref())?; + let session_id = jar.get(super::SESSION_COOKIE).map(|c| c.value().to_owned()); let safe_board = board_short .chars() @@ -759,6 +768,53 @@ mod tests { format!("{prefix}-{}.zip", uuid::Uuid::new_v4().simple()) } + #[tokio::test] + async fn board_backup_get_requires_admin_csrf() { + let state = crate::test_support::app_state(); + install_admin_session(&state); + let board_short = format!("b{}", &uuid::Uuid::new_v4().simple().to_string()[..7]); + { + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, &board_short, "Board", "", false).expect("create board"); + } + let app = Router::new() + .route("/admin/board/backup/{board}", get(super::board_backup)) + .with_state(state); + let cookie = "csrf_token=csrf123; chan_admin_session=session123"; + + let rejected = app + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri(format!("/admin/board/backup/{board_short}")) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("rejected response"); + + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + + let accepted = app + .oneshot( + Request::builder() + .method("GET") + .uri(format!( + "/admin/board/backup/{board_short}?_csrf={}", + admin_signed_csrf() + )) + .header(header::COOKIE, cookie) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("accepted response"); + + assert_eq!(accepted.status(), StatusCode::NOT_FOUND); + } + fn extract_location_query_param(location: &str, key: &str) -> Option { let (_, query) = location.split_once('?')?; query.split('&').find_map(|pair| { @@ -1586,7 +1642,7 @@ mod tests { } } - let backup_root = crate::handlers::admin::backup::v4::backups_root_dir(); + let backup_root = crate::handlers::admin::backup::saved_backup::backups_root_dir(); std::fs::create_dir_all(&backup_root).expect("backup root"); let older_completed_dir = backup_root.join("2099-01-01_000001_full-site-newer-mtime-test"); let newer_dir_mtime_dir = @@ -1595,18 +1651,18 @@ mod tests { older_completed_dir.clone(), newer_dir_mtime_dir.clone(), ]); - crate::handlers::admin::backup::v4::write_saved_v4_fixture_for_test( + crate::handlers::admin::backup::saved_backup::write_saved_v4_fixture_for_test( &older_completed_dir, - crate::handlers::admin::backup::v4::BackupScope::FullSite, - crate::handlers::admin::backup::v4::board_fixture_files_for_test(), + crate::handlers::admin::backup::saved_backup::BackupScope::FullSite, + crate::handlers::admin::backup::saved_backup::board_fixture_files_for_test(), Some(b"sqlite".to_vec()), 4_102_444_800, ); std::thread::sleep(std::time::Duration::from_millis(20)); - crate::handlers::admin::backup::v4::write_saved_v4_fixture_for_test( + crate::handlers::admin::backup::saved_backup::write_saved_v4_fixture_for_test( &newer_dir_mtime_dir, - crate::handlers::admin::backup::v4::BackupScope::FullSite, - crate::handlers::admin::backup::v4::board_fixture_files_for_test(), + crate::handlers::admin::backup::saved_backup::BackupScope::FullSite, + crate::handlers::admin::backup::saved_backup::board_fixture_files_for_test(), Some(b"sqlite".to_vec()), 4_102_444_700, ); diff --git a/src/handlers/admin/backup/archive.rs b/src/handlers/admin/backup/archive.rs index 06209ffb..8426328c 100644 --- a/src/handlers/admin/backup/archive.rs +++ b/src/handlers/admin/backup/archive.rs @@ -184,11 +184,11 @@ fn copy_board_upload_entries_from_full_backup( zip: &mut zip::ZipWriter, zip_path: &str, - source: &v4::VerifiedSavedV4File, + source: &saved_backup::VerifiedSavedV4File, ) -> Result<()> { zip.start_file(zip_path, zip_file_options_for_path(Path::new(zip_path))) .map_err(|error| AppError::Internal(anyhow::anyhow!("Zip {zip_path}: {error}")))?; - v4::copy_verified_file_to_writer(source, zip) + saved_backup::copy_verified_file_to_writer(source, zip) .map_err(|error| AppError::Internal(anyhow::anyhow!("Copy {zip_path}: {error}"))) } @@ -197,7 +197,8 @@ fn temp_legacy_zip_path(prefix: &str) -> PathBuf { } pub(super) fn create_temp_legacy_full_backup_from_v4_path(root_dir: &Path) -> Result { - let verified = v4::verify_saved_v4_root(root_dir, &[v4::BackupScope::FullSite])?; + let verified = + saved_backup::verify_saved_v4_root(root_dir, &[saved_backup::BackupScope::FullSite])?; create_temp_legacy_full_backup_from_verified_v4(&verified) } @@ -228,13 +229,13 @@ pub(super) fn create_temp_legacy_full_backup_from_v4_transfer_zip { + saved_backup::BackupFileKind::OriginalMedia + | saved_backup::BackupFileKind::Thumbnail + | saved_backup::BackupFileKind::Banner + | saved_backup::BackupFileKind::Favicon => { upload_file_count = upload_file_count.saturating_add(1); } _ => {} @@ -320,7 +321,7 @@ pub(super) fn create_temp_legacy_full_backup_from_v4_transfer_zip Result { debug_assert_eq!( verified.metadata.backup_id, verified.manifest.backup_id, @@ -347,27 +348,29 @@ fn create_temp_legacy_full_backup_from_verified_v4( .filter(|entry| { matches!( entry.kind, - v4::BackupFileKind::OriginalMedia - | v4::BackupFileKind::Thumbnail - | v4::BackupFileKind::Banner - | v4::BackupFileKind::Favicon + saved_backup::BackupFileKind::OriginalMedia + | saved_backup::BackupFileKind::Thumbnail + | saved_backup::BackupFileKind::Banner + | saved_backup::BackupFileKind::Favicon ) && entry.board.is_some() }) .count() as u64; let favicon_file_count = manifest .files .iter() - .filter(|entry| entry.kind == v4::BackupFileKind::Favicon && entry.board.is_none()) + .filter(|entry| { + entry.kind == saved_backup::BackupFileKind::Favicon && entry.board.is_none() + }) .count() as u64; let banner_file_count = manifest .files .iter() - .filter(|entry| entry.kind == v4::BackupFileKind::Banner && entry.board.is_none()) + .filter(|entry| entry.kind == saved_backup::BackupFileKind::Banner && entry.board.is_none()) .count() as u64; let tor_hidden_service_key_file_count = manifest .files .iter() - .filter(|entry| entry.kind == v4::BackupFileKind::TorKey) + .filter(|entry| entry.kind == saved_backup::BackupFileKind::TorKey) .count() as u64; let db_bytes = manifest .db_snapshot @@ -413,12 +416,12 @@ fn create_temp_legacy_full_backup_from_verified_v4( for (_, board) in boards { for entry in &board.upload_files { match entry.kind { - v4::BackupFileKind::OriginalMedia - | v4::BackupFileKind::Thumbnail - | v4::BackupFileKind::Banner - | v4::BackupFileKind::Favicon => { + saved_backup::BackupFileKind::OriginalMedia + | saved_backup::BackupFileKind::Thumbnail + | saved_backup::BackupFileKind::Banner + | saved_backup::BackupFileKind::Favicon => { let (runtime_path, _) = - v4::logical_upload_path_to_runtime(&entry.logical_path)?; + saved_backup::logical_upload_path_to_runtime(&entry.logical_path)?; write_v4_file_to_legacy_zip( &mut zip, &format!("uploads/{runtime_path}"), @@ -483,15 +486,18 @@ pub(super) fn create_temp_legacy_board_backup_from_v4_path( root_dir: &Path, board_short: Option<&str>, ) -> Result<(PathBuf, String)> { - let verified = v4::verify_saved_v4_root( + let verified = saved_backup::verify_saved_v4_root( root_dir, - &[v4::BackupScope::Board, v4::BackupScope::SelectedBoards], + &[ + saved_backup::BackupScope::Board, + saved_backup::BackupScope::SelectedBoards, + ], )?; create_temp_legacy_board_backup_from_verified_v4(&verified, board_short) } fn create_temp_legacy_board_backup_from_verified_v4( - verified: &v4::VerifiedSavedV4Root, + verified: &saved_backup::VerifiedSavedV4Root, board_short: Option<&str>, ) -> Result<(PathBuf, String)> { let board_short = match board_short { @@ -510,7 +516,7 @@ fn create_temp_legacy_board_backup_from_verified_v4( let board_layout = verified.boards.get(&board_short).ok_or_else(|| { AppError::NotFound(format!("Board /{board_short}/ not found in this backup.")) })?; - let board_json = v4::read_verified_file(&board_layout.board_json)?; + let board_json = saved_backup::read_verified_file(&board_layout.board_json)?; let temp_zip = temp_legacy_zip_path("rustchan_v4_board_restore"); let filename = format!( @@ -529,7 +535,7 @@ fn create_temp_legacy_board_backup_from_verified_v4( .map_err(|error| AppError::Internal(anyhow::anyhow!("Write board.json: {error}")))?; for entry in &board_layout.upload_files { - let (runtime_path, _) = v4::logical_upload_path_to_runtime(&entry.logical_path)?; + let (runtime_path, _) = saved_backup::logical_upload_path_to_runtime(&entry.logical_path)?; write_v4_file_to_legacy_zip(&mut zip, &format!("uploads/{runtime_path}"), entry)?; } @@ -543,7 +549,8 @@ pub(super) fn create_temp_legacy_board_backup_from_saved_full_v4_path( root_dir: &Path, board_short: &str, ) -> Result<(PathBuf, String)> { - let verified = v4::verify_saved_v4_root(root_dir, &[v4::BackupScope::FullSite])?; + let verified = + saved_backup::verify_saved_v4_root(root_dir, &[saved_backup::BackupScope::FullSite])?; create_temp_legacy_board_backup_from_verified_v4(&verified, Some(board_short)) } @@ -619,11 +626,11 @@ mod tests { fn full_fixture_root(label: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().join(label); - v4::write_saved_v4_fixture_for_test( + saved_backup::write_saved_v4_fixture_for_test( &root, - v4::BackupScope::FullSite, - v4::board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + saved_backup::BackupScope::FullSite, + saved_backup::board_fixture_files_for_test(), + Some(saved_backup::valid_db_snapshot_for_test()), 1_715_010_000_i64, ); (dir, root) @@ -632,10 +639,10 @@ mod tests { fn board_fixture_root(label: &str) -> (tempfile::TempDir, PathBuf) { let dir = tempfile::tempdir().expect("tempdir"); let root = dir.path().join(label); - v4::write_saved_v4_fixture_for_test( + saved_backup::write_saved_v4_fixture_for_test( &root, - v4::BackupScope::Board, - v4::board_fixture_files_for_test(), + saved_backup::BackupScope::Board, + saved_backup::board_fixture_files_for_test(), None, 1_715_020_000_i64, ); @@ -645,9 +652,12 @@ mod tests { #[test] fn saved_full_v4_restore_rejects_db_snapshot_escape() { let (_dir, root) = full_fixture_root("2026-05-06_full-site_db-escape"); - let mut manifest = v4::load_manifest(&root.join(v4::MANIFEST_FILE_NAME)).expect("manifest"); + let mut manifest = + saved_backup::load_manifest(&root.join(saved_backup::MANIFEST_FILE_NAME)) + .expect("manifest"); manifest.db_snapshot.as_mut().expect("db snapshot").path = "../escape.db".to_owned(); - v4::write_json_pretty(&root.join(v4::MANIFEST_FILE_NAME), &manifest).expect("manifest"); + saved_backup::write_json_pretty(&root.join(saved_backup::MANIFEST_FILE_NAME), &manifest) + .expect("manifest"); let error = create_temp_legacy_full_backup_from_v4_path(&root) .expect_err("db snapshot escape should fail"); @@ -663,14 +673,17 @@ mod tests { &favicon_bytes, ) .expect("outside favicon"); - let mut manifest = v4::load_manifest(&root.join(v4::MANIFEST_FILE_NAME)).expect("manifest"); - manifest.files.push(v4::test_file_entry_for_test( + let mut manifest = + saved_backup::load_manifest(&root.join(saved_backup::MANIFEST_FILE_NAME)) + .expect("manifest"); + manifest.files.push(saved_backup::test_file_entry_for_test( "../escape.ico", None, - v4::BackupFileKind::Favicon, + saved_backup::BackupFileKind::Favicon, &favicon_bytes, )); - v4::write_json_pretty(&root.join(v4::MANIFEST_FILE_NAME), &manifest).expect("manifest"); + saved_backup::write_json_pretty(&root.join(saved_backup::MANIFEST_FILE_NAME), &manifest) + .expect("manifest"); let error = create_temp_legacy_full_backup_from_v4_path(&root) .expect_err("favicon escape should fail"); @@ -686,20 +699,27 @@ mod tests { &tor_bytes, ) .expect("outside key"); - let mut manifest = v4::load_manifest(&root.join(v4::MANIFEST_FILE_NAME)).expect("manifest"); + let mut manifest = + saved_backup::load_manifest(&root.join(saved_backup::MANIFEST_FILE_NAME)) + .expect("manifest"); manifest.includes.tor_keys = true; - manifest.files.push(v4::test_file_entry_for_test( + manifest.files.push(saved_backup::test_file_entry_for_test( "../outside.key", None, - v4::BackupFileKind::TorKey, + saved_backup::BackupFileKind::TorKey, &tor_bytes, )); let mut metadata = - v4::load_metadata(&root.join(v4::BACKUP_METADATA_FILE_NAME)).expect("metadata"); + saved_backup::load_metadata(&root.join(saved_backup::BACKUP_METADATA_FILE_NAME)) + .expect("metadata"); metadata.includes_tor_keys = true; - v4::write_json_pretty(&root.join(v4::MANIFEST_FILE_NAME), &manifest).expect("manifest"); - v4::write_json_pretty(&root.join(v4::BACKUP_METADATA_FILE_NAME), &metadata) - .expect("metadata"); + saved_backup::write_json_pretty(&root.join(saved_backup::MANIFEST_FILE_NAME), &manifest) + .expect("manifest"); + saved_backup::write_json_pretty( + &root.join(saved_backup::BACKUP_METADATA_FILE_NAME), + &metadata, + ) + .expect("metadata"); let error = create_temp_legacy_full_backup_from_v4_path(&root) .expect_err("tor key escape should fail"); @@ -709,15 +729,18 @@ mod tests { #[test] fn saved_board_v4_restore_rejects_escaping_manifest_path() { let (_dir, root) = board_fixture_root("2026-05-06_board-escape"); - let mut manifest = v4::load_manifest(&root.join(v4::MANIFEST_FILE_NAME)).expect("manifest"); + let mut manifest = + saved_backup::load_manifest(&root.join(saved_backup::MANIFEST_FILE_NAME)) + .expect("manifest"); if let Some(entry) = manifest .files .iter_mut() - .find(|entry| entry.kind == v4::BackupFileKind::BoardJson) + .find(|entry| entry.kind == saved_backup::BackupFileKind::BoardJson) { entry.logical_path = "../board.json".to_owned(); } - v4::write_json_pretty(&root.join(v4::MANIFEST_FILE_NAME), &manifest).expect("manifest"); + saved_backup::write_json_pretty(&root.join(saved_backup::MANIFEST_FILE_NAME), &manifest) + .expect("manifest"); let error = create_temp_legacy_board_backup_from_v4_path(&root, None) .expect_err("board restore strict verify should fail"); @@ -727,15 +750,18 @@ mod tests { #[test] fn selected_board_extraction_from_saved_full_rejects_escaping_manifest_path() { let (_dir, root) = full_fixture_root("2026-05-06_full-site-board-escape"); - let mut manifest = v4::load_manifest(&root.join(v4::MANIFEST_FILE_NAME)).expect("manifest"); + let mut manifest = + saved_backup::load_manifest(&root.join(saved_backup::MANIFEST_FILE_NAME)) + .expect("manifest"); if let Some(entry) = manifest .files .iter_mut() - .find(|entry| entry.kind == v4::BackupFileKind::BoardJson) + .find(|entry| entry.kind == saved_backup::BackupFileKind::BoardJson) { entry.logical_path = "../board.json".to_owned(); } - v4::write_json_pretty(&root.join(v4::MANIFEST_FILE_NAME), &manifest).expect("manifest"); + saved_backup::write_json_pretty(&root.join(saved_backup::MANIFEST_FILE_NAME), &manifest) + .expect("manifest"); let error = create_temp_legacy_board_backup_from_saved_full_v4_path(&root, "tech") .expect_err("selected-board extraction strict verify should fail"); @@ -750,7 +776,9 @@ mod tests { .expect("create cross-board parent"); std::fs::write(&cross_board_path, b"media").expect("write cross-board file"); - let mut manifest = v4::load_manifest(&root.join(v4::MANIFEST_FILE_NAME)).expect("manifest"); + let mut manifest = + saved_backup::load_manifest(&root.join(saved_backup::MANIFEST_FILE_NAME)) + .expect("manifest"); if let Some(entry) = manifest .files .iter_mut() @@ -758,7 +786,8 @@ mod tests { { entry.logical_path = "boards/other/media/src/example.txt".to_owned(); } - v4::write_json_pretty(&root.join(v4::MANIFEST_FILE_NAME), &manifest).expect("manifest"); + saved_backup::write_json_pretty(&root.join(saved_backup::MANIFEST_FILE_NAME), &manifest) + .expect("manifest"); let error = create_temp_legacy_board_backup_from_saved_full_v4_path(&root, "tech") .expect_err("selected-board extraction cross-board file should fail"); diff --git a/src/handlers/admin/backup/common.rs b/src/handlers/admin/backup/common.rs index c5baecb7..c46b9375 100644 --- a/src/handlers/admin/backup/common.rs +++ b/src/handlers/admin/backup/common.rs @@ -430,7 +430,15 @@ pub(super) fn verify_full_backup_archive( "Invalid full backup: chan.db does not look like a SQLite database.".into(), )); } + if db_entry.size() != manifest.db_bytes { + return Err(AppError::BadRequest(format!( + "Invalid full backup: manifest database size {} does not match archive size {}.", + manifest.db_bytes, + db_entry.size() + ))); + } drop(db_entry); + verify_full_backup_db_schema(archive, manifest.db_bytes)?; let mut upload_file_count = 0u64; let mut favicon_file_count = 0u64; @@ -490,6 +498,45 @@ pub(super) fn verify_full_backup_archive( Ok(manifest) } +fn verify_full_backup_db_schema( + archive: &mut zip::ZipArchive, + expected_db_bytes: u64, +) -> Result<()> { + if expected_db_bytes > ZIP_ENTRY_MAX_BYTES { + return Err(AppError::BadRequest( + "Invalid full backup: chan.db exceeds the restore entry size limit.".into(), + )); + } + let mut db_entry = archive.by_name("chan.db").map_err(|_error| { + AppError::BadRequest("Invalid full backup: zip must contain 'chan.db' at the root.".into()) + })?; + let mut temp_db = tempfile::NamedTempFile::new().map_err(|error| { + AppError::Internal(anyhow::anyhow!( + "Create temporary database validation file: {error}" + )) + })?; + let copied = std::io::copy(&mut db_entry, temp_db.as_file_mut()).map_err(|error| { + AppError::BadRequest(format!("Invalid full backup database entry: {error}")) + })?; + if copied != expected_db_bytes { + return Err(AppError::BadRequest(format!( + "Invalid full backup: copied database size {copied} does not match manifest size {expected_db_bytes}." + ))); + } + let conn = rusqlite::Connection::open(temp_db.path()).map_err(|error| { + AppError::BadRequest(format!( + "Invalid full backup: chan.db could not be opened as SQLite: {error}" + )) + })?; + crate::db::normalize_database_schema_version(&conn).map_err(|error| { + AppError::BadRequest(format!( + "Invalid full backup: chan.db does not match the RustChan {} database baseline: {error}", + crate::db::baseline_schema_version() + )) + })?; + Ok(()) +} + pub(super) fn verify_full_backup_zip(path: &Path) -> Result { let file = std::fs::File::open(path).map_err(|error| { AppError::Internal(anyhow::anyhow!("Open backup {}: {error}", path.display())) @@ -563,6 +610,16 @@ mod tests { zip.finish().expect("finish zip"); } + fn partial_sqlite_db_bytes_for_test() -> Vec { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let db_path = temp_dir.path().join("partial.sqlite3"); + let conn = rusqlite::Connection::open(&db_path).expect("open sqlite"); + conn.execute("CREATE TABLE boards (id INTEGER PRIMARY KEY)", []) + .expect("create partial table"); + drop(conn); + std::fs::read(db_path).expect("read partial db") + } + #[test] fn validate_board_short_name_rejects_path_traversal() { assert!(validate_board_short_name("test").is_ok()); @@ -720,11 +777,12 @@ mod tests { fn verify_full_backup_zip_accepts_manifest_backed_archive() { let temp_dir = tempfile::tempdir().expect("tempdir"); let zip_path = temp_dir.path().join("full.zip"); + let db_bytes = super::super::saved_backup::valid_db_snapshot_for_test(); let manifest = FullBackupManifest { version: 1, generated_at: 1_700_000_000, rustchan_version: "1.1.3".into(), - db_bytes: 4096, + db_bytes: u64::try_from(db_bytes.len()).expect("db size"), upload_file_count: 1, favicon_file_count: 1, banner_file_count: 0, @@ -740,7 +798,7 @@ mod tests { &zip_path, &[ (FULL_BACKUP_MANIFEST_NAME, &manifest_json), - ("chan.db", b"SQLite format 3\0rest of db"), + ("chan.db", db_bytes.as_slice()), ("uploads/b/test.webp", b"img"), ("favicon/favicon-32x32.png", b"icon"), ], @@ -752,6 +810,41 @@ mod tests { assert_eq!(verified.boards.len(), 1); } + #[test] + fn verify_full_backup_zip_rejects_structurally_invalid_database() { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let zip_path = temp_dir.path().join("invalid-db.zip"); + let db_bytes = partial_sqlite_db_bytes_for_test(); + let manifest = FullBackupManifest { + version: 3, + generated_at: 1_700_000_000, + rustchan_version: "1.3.0".into(), + db_bytes: u64::try_from(db_bytes.len()).expect("db size"), + upload_file_count: 0, + favicon_file_count: 0, + banner_file_count: 0, + tor_hidden_service_keys_included: false, + tor_hidden_service_key_file_count: 0, + boards: Vec::new(), + }; + let manifest_json = serde_json::to_vec(&manifest).expect("manifest json"); + write_zip( + &zip_path, + &[ + (FULL_BACKUP_MANIFEST_NAME, &manifest_json), + ("chan.db", db_bytes.as_slice()), + ], + ); + + let error = verify_full_backup_zip(&zip_path).expect_err("invalid db rejected"); + assert!( + error + .to_string() + .contains("does not match the RustChan 1.3.0 database baseline"), + "unexpected error: {error:#}" + ); + } + #[test] fn verify_full_backup_zip_rejects_missing_manifest() { let temp_dir = tempfile::tempdir().expect("tempdir"); @@ -767,11 +860,12 @@ mod tests { fn verify_full_backup_zip_defaults_legacy_tor_metadata_to_not_included() { let temp_dir = tempfile::tempdir().expect("tempdir"); let zip_path = temp_dir.path().join("legacy-full.zip"); + let db_bytes = super::super::saved_backup::valid_db_snapshot_for_test(); let manifest = json!({ "version": 2, "generated_at": 1_700_000_000_i64, "rustchan_version": "1.1.3", - "db_bytes": 4096_u64, + "db_bytes": u64::try_from(db_bytes.len()).expect("db size"), "upload_file_count": 1_u64, "favicon_file_count": 0_u64, "banner_file_count": 0_u64, @@ -782,7 +876,7 @@ mod tests { &zip_path, &[ (FULL_BACKUP_MANIFEST_NAME, &manifest_bytes), - ("chan.db", b"SQLite format 3\0db"), + ("chan.db", db_bytes.as_slice()), ("uploads/tech/file.txt", b"ok"), ], ); @@ -796,11 +890,12 @@ mod tests { fn verify_full_backup_zip_rejects_tor_manifest_mismatch() { let temp_dir = tempfile::tempdir().expect("tempdir"); let zip_path = temp_dir.path().join("tor-mismatch.zip"); + let db_bytes = super::super::saved_backup::valid_db_snapshot_for_test(); let manifest = FullBackupManifest { version: 3, generated_at: 1_700_000_000, rustchan_version: "1.1.3".into(), - db_bytes: 4096, + db_bytes: u64::try_from(db_bytes.len()).expect("db size"), upload_file_count: 0, favicon_file_count: 0, banner_file_count: 0, @@ -813,7 +908,7 @@ mod tests { &zip_path, &[ (FULL_BACKUP_MANIFEST_NAME, &manifest_bytes), - ("chan.db", b"SQLite format 3\0db"), + ("chan.db", db_bytes.as_slice()), ], ); diff --git a/src/handlers/admin/backup/create.rs b/src/handlers/admin/backup/create.rs index f33b36ac..2c736317 100644 --- a/src/handlers/admin/backup/create.rs +++ b/src/handlers/admin/backup/create.rs @@ -8,7 +8,7 @@ pub(crate) fn create_full_backup_to_server( progress: &std::sync::Arc, copies_to_keep: u64, include_tor_hidden_service_keys: bool, - storage_mode: v4::BackupStorageMode, + storage_mode: saved_backup::BackupStorageMode, split_zip_part_size: u64, ) -> Result { let conn = pool.get()?; @@ -74,8 +74,8 @@ pub(crate) fn create_full_backup_to_server( .saturating_add(favicon_file_count) .saturating_add(banner_file_count) .saturating_add(tor_hidden_service_key_file_count); - let backup_id = v4::build_backup_id(v4::BackupScope::FullSite, "full-site"); - let root_dir = v4::create_backup_root(&backup_id)?; + let backup_id = saved_backup::build_backup_id(saved_backup::BackupScope::FullSite, "full-site"); + let root_dir = saved_backup::create_backup_root(&backup_id)?; let db_dir = root_dir.join("db"); let config_dir = root_dir.join("config"); std::fs::create_dir_all(&db_dir).map_err(|error| { @@ -97,7 +97,7 @@ pub(crate) fn create_full_backup_to_server( let db_snapshot_size = std::fs::metadata(&db_snapshot_path) .map(|metadata| metadata.len()) .map_err(|error| AppError::Internal(anyhow::anyhow!("Stat DB snapshot: {error}")))?; - let db_snapshot_sha = v4::sha256_hex_for_file(&db_snapshot_path)?; + let db_snapshot_sha = saved_backup::sha256_hex_for_file(&db_snapshot_path)?; let mut files = Vec::new(); push_v4_file_entry( @@ -105,7 +105,7 @@ pub(crate) fn create_full_backup_to_server( "db/rustchan.sqlite3".to_owned(), None, None, - v4::BackupFileKind::Db, + saved_backup::BackupFileKind::Db, db_snapshot_size, db_snapshot_sha.clone(), ); @@ -120,7 +120,7 @@ pub(crate) fn create_full_backup_to_server( "config/settings.toml".to_owned(), None, None, - v4::BackupFileKind::Settings, + saved_backup::BackupFileKind::Settings, size, sha256, ); @@ -148,7 +148,7 @@ pub(crate) fn create_full_backup_to_server( })?; super::common::validate_board_short_name(board_short)?; let (logical_path, kind) = - v4::runtime_upload_path_to_logical(board_short, runtime_rel)?; + saved_backup::runtime_upload_path_to_logical(board_short, runtime_rel)?; Ok(( logical_path, Some(runtime_rel.to_owned()), @@ -169,7 +169,7 @@ pub(crate) fn create_full_backup_to_server( logical_path, Some(format!("favicon/{runtime_rel}")), None, - v4::BackupFileKind::Favicon, + saved_backup::BackupFileKind::Favicon, )) }, Some(progress), @@ -185,7 +185,7 @@ pub(crate) fn create_full_backup_to_server( logical_path, Some(format!("banner/{runtime_rel}")), None, - v4::BackupFileKind::Banner, + saved_backup::BackupFileKind::Banner, )) }, Some(progress), @@ -202,24 +202,24 @@ pub(crate) fn create_full_backup_to_server( logical_path, Some(runtime_rel.to_owned()), None, - v4::BackupFileKind::TorKey, + saved_backup::BackupFileKind::TorKey, )) }, Some(progress), )?; } - let mut manifest = v4::BackupManifest { - format: v4::BACKUP_V4_FORMAT.to_owned(), - archive_container: v4::BACKUP_V4_ARCHIVE_CONTAINER.to_owned(), + let mut manifest = saved_backup::BackupManifest { + format: saved_backup::BACKUP_V4_FORMAT.to_owned(), + archive_container: saved_backup::BACKUP_V4_ARCHIVE_CONTAINER.to_owned(), backup_id, created_at: Utc::now().timestamp(), completed_at: None, rustchan_version: env!("CARGO_PKG_VERSION").to_owned(), - scope: v4::BackupScope::FullSite, + scope: saved_backup::BackupScope::FullSite, storage_mode, included_boards: boards, - includes: v4::BackupIncludeFlags { + includes: saved_backup::BackupIncludeFlags { database: true, settings: settings_path.is_file(), uploads: true, @@ -228,7 +228,7 @@ pub(crate) fn create_full_backup_to_server( board_exports: true, file_inventory: true, }, - db_snapshot: Some(v4::DbSnapshotInfo { + db_snapshot: Some(saved_backup::DbSnapshotInfo { path: "db/rustchan.sqlite3".to_owned(), size: db_snapshot_size, sha256: db_snapshot_sha, @@ -239,7 +239,7 @@ pub(crate) fn create_full_backup_to_server( parts: Vec::new(), maintenance: None, }; - if storage_mode == v4::BackupStorageMode::SplitZip { + if storage_mode == saved_backup::BackupStorageMode::SplitZip { materialize_split_zip_parts(&root_dir, &mut manifest, split_zip_part_size)?; } drop(conn); @@ -267,7 +267,7 @@ pub(crate) fn create_full_backup_to_server( } } - let size = v4::scan_dir_stats(&root_dir).bytes; + let size = saved_backup::scan_dir_stats(&root_dir).bytes; tracing::info!( target: "admin", backup_id = %backup_ref, @@ -292,8 +292,9 @@ pub(crate) fn create_pre_maintenance_backup_to_server( reason: &str, ) -> Result { let conn = pool.get()?; - let backup_id = v4::build_backup_id(v4::BackupScope::PreMaintenance, "pre-repair-db"); - let root_dir = v4::create_backup_root(&backup_id)?; + let backup_id = + saved_backup::build_backup_id(saved_backup::BackupScope::PreMaintenance, "pre-repair-db"); + let root_dir = saved_backup::create_backup_root(&backup_id)?; let db_dir = root_dir.join("db"); let config_dir = root_dir.join("config"); let maintenance_dir = root_dir.join("maintenance"); @@ -323,7 +324,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( let db_snapshot_size = std::fs::metadata(&db_snapshot_path) .map(|metadata| metadata.len()) .map_err(|error| AppError::Internal(anyhow::anyhow!("Stat DB snapshot: {error}")))?; - let db_snapshot_sha = v4::sha256_hex_for_file(&db_snapshot_path)?; + let db_snapshot_sha = saved_backup::sha256_hex_for_file(&db_snapshot_path)?; let mut files = Vec::new(); push_v4_file_entry( @@ -331,7 +332,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "db/rustchan.sqlite3".to_owned(), None, None, - v4::BackupFileKind::Db, + saved_backup::BackupFileKind::Db, db_snapshot_size, db_snapshot_sha.clone(), ); @@ -345,7 +346,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "config/settings.toml".to_owned(), None, None, - v4::BackupFileKind::Settings, + saved_backup::BackupFileKind::Settings, size, sha256, ); @@ -369,7 +370,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "maintenance/repair-request.json".to_owned(), None, None, - v4::BackupFileKind::Maintenance, + saved_backup::BackupFileKind::Maintenance, request_size, request_sha, ); @@ -386,9 +387,9 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "maintenance/pre-integrity-check.txt".to_owned(), None, None, - v4::BackupFileKind::Maintenance, + saved_backup::BackupFileKind::Maintenance, u64::try_from(pre_integrity.len()).unwrap_or(u64::MAX), - v4::sha256_hex_for_bytes(pre_integrity.as_bytes()), + saved_backup::sha256_hex_for_bytes(pre_integrity.as_bytes()), ); let foreign_key_path = maintenance_dir.join("pre-foreign-key-check.txt"); @@ -403,9 +404,9 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "maintenance/pre-foreign-key-check.txt".to_owned(), None, None, - v4::BackupFileKind::Maintenance, + saved_backup::BackupFileKind::Maintenance, u64::try_from(pre_foreign_key.len()).unwrap_or(u64::MAX), - v4::sha256_hex_for_bytes(pre_foreign_key.as_bytes()), + saved_backup::sha256_hex_for_bytes(pre_foreign_key.as_bytes()), ); let schema_dump = { @@ -438,9 +439,9 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "maintenance/pre-schema.sql".to_owned(), None, None, - v4::BackupFileKind::Maintenance, + saved_backup::BackupFileKind::Maintenance, u64::try_from(schema_dump.len()).unwrap_or(u64::MAX), - v4::sha256_hex_for_bytes(schema_dump.as_bytes()), + saved_backup::sha256_hex_for_bytes(schema_dump.as_bytes()), ); let pending_fs_ops = crate::db::list_pending_fs_ops(&conn) @@ -463,7 +464,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( "maintenance/pending-fs-ops.json".to_owned(), None, None, - v4::BackupFileKind::PendingFsOps, + saved_backup::BackupFileKind::PendingFsOps, size, sha256, ); @@ -472,17 +473,17 @@ pub(crate) fn create_pre_maintenance_backup_to_server( progress.reset(crate::middleware::backup_phase::DONE); log_backup_phase(crate::middleware::backup_phase::DONE); - let manifest = v4::BackupManifest { - format: v4::BACKUP_V4_FORMAT.to_owned(), - archive_container: v4::BACKUP_V4_ARCHIVE_CONTAINER.to_owned(), + let manifest = saved_backup::BackupManifest { + format: saved_backup::BACKUP_V4_FORMAT.to_owned(), + archive_container: saved_backup::BACKUP_V4_ARCHIVE_CONTAINER.to_owned(), backup_id, created_at: Utc::now().timestamp(), completed_at: None, rustchan_version: env!("CARGO_PKG_VERSION").to_owned(), - scope: v4::BackupScope::PreMaintenance, - storage_mode: v4::BackupStorageMode::Directory, + scope: saved_backup::BackupScope::PreMaintenance, + storage_mode: saved_backup::BackupStorageMode::Directory, included_boards: Vec::new(), - includes: v4::BackupIncludeFlags { + includes: saved_backup::BackupIncludeFlags { database: true, settings: settings_path.is_file(), uploads: false, @@ -491,7 +492,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( board_exports: false, file_inventory: false, }, - db_snapshot: Some(v4::DbSnapshotInfo { + db_snapshot: Some(saved_backup::DbSnapshotInfo { path: "db/rustchan.sqlite3".to_owned(), size: db_snapshot_size, sha256: db_snapshot_sha, @@ -500,7 +501,7 @@ pub(crate) fn create_pre_maintenance_backup_to_server( }), files, parts: Vec::new(), - maintenance: Some(v4::MaintenanceMetadata { + maintenance: Some(saved_backup::MaintenanceMetadata { operation: Some(operation.to_owned()), job_id: Some(job_id), requested_at: Some(Utc::now().timestamp()), @@ -591,7 +592,7 @@ fn write_pretty_json_file(path: &Path, value: &T) -> Result })?; Ok(( u64::try_from(bytes.len()).unwrap_or(u64::MAX), - v4::sha256_hex_for_bytes(&bytes), + saved_backup::sha256_hex_for_bytes(&bytes), )) } @@ -615,7 +616,7 @@ fn copy_regular_file_to_backup(source: &Path, destination: &Path) -> Result<(u64 let mut output = std::fs::File::create(destination).map_err(|error| { AppError::Internal(anyhow::anyhow!("Create {}: {error}", destination.display())) })?; - v4::copy_file_and_hash(source, &mut output) + saved_backup::copy_file_and_hash(source, &mut output) } fn snapshot_db_health_output(conn: &rusqlite::Connection, pragma: &str) -> Option { @@ -649,10 +650,10 @@ const MAX_SPLIT_ZIP_PART_SIZE: u64 = 64 * 1024 * 1024 * 1024; pub(crate) fn parse_backup_storage_mode_value( value: Option<&str>, -) -> Result { +) -> Result { match value.unwrap_or("directory") { - "directory" => Ok(v4::BackupStorageMode::Directory), - "split_zip" => Ok(v4::BackupStorageMode::SplitZip), + "directory" => Ok(saved_backup::BackupStorageMode::Directory), + "split_zip" => Ok(saved_backup::BackupStorageMode::SplitZip), _ => Err(AppError::BadRequest("Unknown backup storage mode.".into())), } } @@ -674,7 +675,9 @@ pub(crate) const fn split_zip_part_size_gib(bytes: u64) -> u64 { bytes / (1024 * 1024 * 1024) } -fn parse_full_backup_storage_mode(form: &FullBackupCreateForm) -> Result { +fn parse_full_backup_storage_mode( + form: &FullBackupCreateForm, +) -> Result { parse_backup_storage_mode_value(form.storage_mode.as_deref()) } @@ -704,7 +707,7 @@ pub async fn create_full_backup( let copies_to_keep = state.auto_full_backup_settings.snapshot().copies_to_keep; let include_tor_hidden_service_keys = form.include_tor_hidden_service_keys; let storage_mode = parse_full_backup_storage_mode(&form)?; - let split_zip_part_size = if storage_mode == v4::BackupStorageMode::SplitZip { + let split_zip_part_size = if storage_mode == saved_backup::BackupStorageMode::SplitZip { parse_split_zip_part_size(&form)? } else { DEFAULT_SPLIT_ZIP_PART_SIZE @@ -881,9 +884,11 @@ pub async fn create_board_backup( .files_total .store(file_count.saturating_add(1), Ordering::Relaxed); - let backup_id = - v4::build_backup_id(v4::BackupScope::Board, &format!("board-{board_short}")); - let root_dir = v4::create_backup_root(&backup_id)?; + let backup_id = saved_backup::build_backup_id( + saved_backup::BackupScope::Board, + &format!("board-{board_short}"), + ); + let root_dir = saved_backup::create_backup_root(&backup_id)?; let boards = vec![crate::models::BackupBoardSummary { short_name: manifest.board.short_name.clone(), name: manifest.board.name.clone(), @@ -899,7 +904,7 @@ pub async fn create_board_backup( |_path, runtime_rel| { let runtime_rel = format!("{board_short}/{runtime_rel}"); let (logical_path, kind) = - v4::runtime_upload_path_to_logical(&board_short, &runtime_rel)?; + saved_backup::runtime_upload_path_to_logical(&board_short, &runtime_rel)?; Ok(( logical_path, Some(runtime_rel), @@ -910,17 +915,17 @@ pub async fn create_board_backup( Some(&progress), )?; - let manifest_v4 = v4::BackupManifest { - format: v4::BACKUP_V4_FORMAT.to_owned(), - archive_container: v4::BACKUP_V4_ARCHIVE_CONTAINER.to_owned(), + let manifest_v4 = saved_backup::BackupManifest { + format: saved_backup::BACKUP_V4_FORMAT.to_owned(), + archive_container: saved_backup::BACKUP_V4_ARCHIVE_CONTAINER.to_owned(), backup_id, created_at: Utc::now().timestamp(), completed_at: None, rustchan_version: env!("CARGO_PKG_VERSION").to_owned(), - scope: v4::BackupScope::Board, - storage_mode: v4::BackupStorageMode::Directory, + scope: saved_backup::BackupScope::Board, + storage_mode: saved_backup::BackupStorageMode::Directory, included_boards: boards, - includes: v4::BackupIncludeFlags { + includes: saved_backup::BackupIncludeFlags { database: false, settings: false, uploads: true, @@ -941,7 +946,7 @@ pub async fn create_board_backup( super::BackupListKind::Board, ); - let size = v4::scan_dir_stats(&root_dir).bytes; + let size = saved_backup::scan_dir_stats(&root_dir).bytes; tracing::info!( target: "admin", board = %board_short, @@ -1365,15 +1370,15 @@ fn collect_backup_board_summaries( } fn push_v4_file_entry( - entries: &mut Vec, + entries: &mut Vec, logical_path: String, runtime_logical_path: Option, board: Option, - kind: v4::BackupFileKind, + kind: saved_backup::BackupFileKind, size: u64, sha256: String, ) { - entries.push(v4::BackupFileEntry { + entries.push(saved_backup::BackupFileEntry { logical_path, runtime_logical_path, board, @@ -1394,13 +1399,13 @@ struct SplitZipPlannedPart { } fn plan_split_zip_parts( - files: &[v4::BackupFileEntry], + files: &[saved_backup::BackupFileEntry], target_part_size: u64, ) -> Vec { let mut ordered = files .iter() .enumerate() - .collect::>(); + .collect::>(); ordered.sort_by(|left, right| left.1.logical_path.cmp(&right.1.logical_path)); let mut parts = Vec::new(); @@ -1438,10 +1443,10 @@ fn plan_split_zip_parts( fn materialize_split_zip_parts( root_dir: &Path, - manifest: &mut v4::BackupManifest, + manifest: &mut saved_backup::BackupManifest, target_part_size: u64, ) -> Result<()> { - let parts_dir = root_dir.join(v4::PARTS_DIR_NAME); + let parts_dir = root_dir.join(saved_backup::PARTS_DIR_NAME); std::fs::create_dir_all(&parts_dir).map_err(|error| { AppError::Internal(anyhow::anyhow!("Create {}: {error}", parts_dir.display())) })?; @@ -1515,7 +1520,7 @@ fn materialize_split_zip_parts( .map_err(|error| { AppError::Internal(anyhow::anyhow!("Inspect {}: {error}", part_path.display())) })?; - let part_sha = v4::sha256_hex_for_file(&part_path)?; + let part_sha = saved_backup::sha256_hex_for_file(&part_path)?; for file_index in &planned.files { let entry = manifest.files.get_mut(*file_index).ok_or_else(|| { AppError::Internal(anyhow::anyhow!("Invalid split ZIP planner index")) @@ -1524,7 +1529,7 @@ fn materialize_split_zip_parts( entry.zip_entry_path = Some(entry.logical_path.clone()); entry.compression_method = Some("zip".to_owned()); } - part_infos.push(v4::BackupPartInfo { + part_infos.push(saved_backup::BackupPartInfo { filename: part_filename, part_index, total_parts, @@ -1557,18 +1562,26 @@ fn materialize_split_zip_parts( fn copy_runtime_tree_into_v4_dir( source_root: &Path, destination_root: &Path, - entries: &mut Vec, + entries: &mut Vec, mut map_entry: F, progress: Option<&crate::middleware::BackupProgress>, ) -> Result<()> where - F: FnMut(&Path, &str) -> Result<(String, Option, Option, v4::BackupFileKind)>, + F: FnMut( + &Path, + &str, + ) -> Result<( + String, + Option, + Option, + saved_backup::BackupFileKind, + )>, { fn visit( current: &Path, source_root: &Path, destination_root: &Path, - entries: &mut Vec, + entries: &mut Vec, map_entry: &mut F, progress: Option<&crate::middleware::BackupProgress>, ) -> Result<()> @@ -1576,7 +1589,12 @@ where F: FnMut( &Path, &str, - ) -> Result<(String, Option, Option, v4::BackupFileKind)>, + ) -> Result<( + String, + Option, + Option, + saved_backup::BackupFileKind, + )>, { let dir_entries = std::fs::read_dir(current).map_err(|error| { AppError::Internal(anyhow::anyhow!("Read {}: {error}", current.display())) @@ -1613,7 +1631,7 @@ where } let runtime_rel = relative_path_string(&path, source_root)?; let (logical_path, runtime_logical_path, board, kind) = map_entry(&path, &runtime_rel)?; - v4::sanitize_logical_path(&logical_path)?; + saved_backup::sanitize_logical_path(&logical_path)?; let destination = destination_root.join(&logical_path); let (size, sha256) = copy_regular_file_to_backup(&path, &destination)?; push_v4_file_entry( @@ -1650,7 +1668,7 @@ where fn write_board_exports_to_v4_dir( destination_root: &Path, manifest: &board_backup_types::BoardBackupManifest, - entries: &mut Vec, + entries: &mut Vec, ) -> Result<()> { super::common::validate_board_short_name(&manifest.board.short_name)?; let board_root = destination_root @@ -1667,7 +1685,7 @@ fn write_board_exports_to_v4_dir( format!("boards/{}/board.json", manifest.board.short_name), None, Some(manifest.board.short_name.clone()), - v4::BackupFileKind::BoardJson, + saved_backup::BackupFileKind::BoardJson, board_json_size, board_json_sha, ); @@ -1679,7 +1697,7 @@ fn write_board_exports_to_v4_dir( format!("boards/{}/threads.jsonl", manifest.board.short_name), None, Some(manifest.board.short_name.clone()), - v4::BackupFileKind::ThreadExport, + saved_backup::BackupFileKind::ThreadExport, threads_size, threads_sha, ); @@ -1691,7 +1709,7 @@ fn write_board_exports_to_v4_dir( format!("boards/{}/posts.jsonl", manifest.board.short_name), None, Some(manifest.board.short_name.clone()), - v4::BackupFileKind::PostExport, + saved_backup::BackupFileKind::PostExport, posts_size, posts_sha, ); @@ -1703,18 +1721,21 @@ fn write_board_exports_to_v4_dir( format!("boards/{}/files.jsonl", manifest.board.short_name), None, Some(manifest.board.short_name.clone()), - v4::BackupFileKind::FileInventoryExport, + saved_backup::BackupFileKind::FileInventoryExport, files_size, files_sha, ); Ok(()) } -fn finalize_v4_backup_root(root_dir: &Path, mut manifest: v4::BackupManifest) -> Result { +fn finalize_v4_backup_root( + root_dir: &Path, + mut manifest: saved_backup::BackupManifest, +) -> Result { manifest.completed_at = Some(Utc::now().timestamp()); - let mut metadata = v4::BackupMetadata { - format: v4::BACKUP_V4_FORMAT.to_owned(), + let mut metadata = saved_backup::BackupMetadata { + format: saved_backup::BACKUP_V4_FORMAT.to_owned(), backup_id: manifest.backup_id.clone(), scope: manifest.scope, storage_mode: manifest.storage_mode, @@ -1725,17 +1746,22 @@ fn finalize_v4_backup_root(root_dir: &Path, mut manifest: v4::BackupManifest) -> part_count: u32::try_from(manifest.parts.len()).unwrap_or(u32::MAX), includes_tor_keys: manifest.includes.tor_keys, included_boards: manifest.included_boards.clone(), - manifest_path: Some(root_dir.join(v4::MANIFEST_FILE_NAME).display().to_string()), + manifest_path: Some( + root_dir + .join(saved_backup::MANIFEST_FILE_NAME) + .display() + .to_string(), + ), }; - let manifest_path = root_dir.join(v4::MANIFEST_FILE_NAME); - let metadata_path = root_dir.join(v4::BACKUP_METADATA_FILE_NAME); - let readme_path = root_dir.join(v4::README_FILE_NAME); + let manifest_path = root_dir.join(saved_backup::MANIFEST_FILE_NAME); + let metadata_path = root_dir.join(saved_backup::BACKUP_METADATA_FILE_NAME); + let readme_path = root_dir.join(saved_backup::README_FILE_NAME); - v4::write_json_pretty(&manifest_path, &manifest)?; - v4::write_json_pretty(&metadata_path, &metadata)?; - let readme = v4::build_readme(&manifest, &metadata, manifest.includes.tor_keys); - v4::write_text(&readme_path, &readme)?; + saved_backup::write_json_pretty(&manifest_path, &manifest)?; + saved_backup::write_json_pretty(&metadata_path, &metadata)?; + let readme = saved_backup::build_readme(&manifest, &metadata, manifest.includes.tor_keys); + saved_backup::write_text(&readme_path, &readme)?; let part_paths = manifest .parts @@ -1743,10 +1769,10 @@ fn finalize_v4_backup_root(root_dir: &Path, mut manifest: v4::BackupManifest) -> .map(|part| root_dir.join(&part.filename)) .collect::>(); let part_path_refs = part_paths.iter().map(PathBuf::as_path).collect::>(); - v4::write_root_checksums(root_dir, &part_path_refs)?; - metadata.total_size_bytes = v4::scan_dir_stats(root_dir).bytes; - v4::write_json_pretty(&metadata_path, &metadata)?; - v4::write_root_checksums(root_dir, &part_path_refs)?; + saved_backup::write_root_checksums(root_dir, &part_path_refs)?; + metadata.total_size_bytes = saved_backup::scan_dir_stats(root_dir).bytes; + saved_backup::write_json_pretty(&metadata_path, &metadata)?; + saved_backup::write_root_checksums(root_dir, &part_path_refs)?; Ok(manifest.backup_id) } @@ -1758,7 +1784,7 @@ mod tests { TorHiddenServiceKeysAvailability, FULL_BACKUP_MANIFEST_NAME, FULL_BACKUP_TOR_KEYS_ENTRY_PREFIX, }; - use crate::handlers::admin::backup::v4; + use crate::handlers::admin::backup::saved_backup; use axum::{ body::{to_bytes, Body}, extract::Form, @@ -1957,22 +1983,22 @@ mod tests { #[test] fn split_zip_part_planner_does_not_create_empty_parts() { let files = vec![ - v4::BackupFileEntry { + saved_backup::BackupFileEntry { logical_path: "b.txt".to_owned(), runtime_logical_path: None, board: None, - kind: v4::BackupFileKind::Settings, + kind: saved_backup::BackupFileKind::Settings, size: 6, sha256: "b".to_owned(), zip_part: None, zip_entry_path: None, compression_method: None, }, - v4::BackupFileEntry { + saved_backup::BackupFileEntry { logical_path: "a.txt".to_owned(), runtime_logical_path: None, board: None, - kind: v4::BackupFileKind::Settings, + kind: saved_backup::BackupFileKind::Settings, size: 6, sha256: "a".to_owned(), zip_part: None, @@ -1989,11 +2015,11 @@ mod tests { #[test] fn split_zip_part_planner_marks_oversized_single_file_part() { - let files = vec![v4::BackupFileEntry { + let files = vec![saved_backup::BackupFileEntry { logical_path: "huge.bin".to_owned(), runtime_logical_path: None, board: None, - kind: v4::BackupFileKind::OriginalMedia, + kind: saved_backup::BackupFileKind::OriginalMedia, size: 128, sha256: "huge".to_owned(), zip_part: None, diff --git a/src/handlers/admin/backup/downloads.rs b/src/handlers/admin/backup/downloads.rs index f60e9f85..8b171085 100644 --- a/src/handlers/admin/backup/downloads.rs +++ b/src/handlers/admin/backup/downloads.rs @@ -192,12 +192,12 @@ pub async fn download_backup( } let safe_part = sanitize_backup_zip_filename(part_name)?; let backup_root = crate::config::backups_dir().join(&safe_filename); - let expected_scopes: &[v4::BackupScope] = match kind.as_str() { - "full" => &[v4::BackupScope::FullSite], - "board" => &[v4::BackupScope::Board], + let expected_scopes: &[saved_backup::BackupScope] = match kind.as_str() { + "full" => &[saved_backup::BackupScope::FullSite], + "board" => &[saved_backup::BackupScope::Board], _ => unreachable!("validated above"), }; - let verified = v4::verify_saved_v4_root(&backup_root, expected_scopes)?; + let verified = saved_backup::verify_saved_v4_root(&backup_root, expected_scopes)?; let part_filename = format!("parts/{safe_part}"); let part = verified .manifest @@ -221,7 +221,7 @@ pub async fn download_backup( "Backup part size changed since verification.".into(), )); } - let file_sha256 = v4::sha256_hex_for_file(&resolved)?; + let file_sha256 = saved_backup::sha256_hex_for_file(&resolved)?; if file_sha256 != part.sha256 { return Err(AppError::BadRequest( "Backup part checksum changed since verification.".into(), diff --git a/src/handlers/admin/backup/listing.rs b/src/handlers/admin/backup/listing.rs index 202257a3..2dc8c61b 100644 --- a/src/handlers/admin/backup/listing.rs +++ b/src/handlers/admin/backup/listing.rs @@ -47,7 +47,7 @@ fn current_dir_modified(dir: &Path) -> Option { fn current_source_modified(dir: &Path) -> Option { let mut modified = current_dir_modified(dir); - let root_modified = current_dir_modified(&v4::backups_root_dir()); + let root_modified = current_dir_modified(&saved_backup::backups_root_dir()); if root_modified > modified { modified = root_modified; } @@ -69,31 +69,31 @@ fn modified_string_from_epoch(epoch: Option) -> String { .unwrap_or_default() } -const fn metadata_scope_matches(kind: BackupListKind, scope: v4::BackupScope) -> bool { +const fn metadata_scope_matches(kind: BackupListKind, scope: saved_backup::BackupScope) -> bool { match kind { BackupListKind::Full => matches!( scope, - v4::BackupScope::FullSite - | v4::BackupScope::SelectedBoards - | v4::BackupScope::PreMaintenance + saved_backup::BackupScope::FullSite + | saved_backup::BackupScope::SelectedBoards + | saved_backup::BackupScope::PreMaintenance ), - BackupListKind::Board => matches!(scope, v4::BackupScope::Board), + BackupListKind::Board => matches!(scope, saved_backup::BackupScope::Board), } } -fn scope_label(scope: v4::BackupScope) -> String { +fn scope_label(scope: saved_backup::BackupScope) -> String { match scope { - v4::BackupScope::FullSite => "Full site".to_owned(), - v4::BackupScope::Board => "Board".to_owned(), - v4::BackupScope::SelectedBoards => "Selected boards".to_owned(), - v4::BackupScope::PreMaintenance => "Pre-maintenance".to_owned(), + saved_backup::BackupScope::FullSite => "Full site".to_owned(), + saved_backup::BackupScope::Board => "Board".to_owned(), + saved_backup::BackupScope::SelectedBoards => "Selected boards".to_owned(), + saved_backup::BackupScope::PreMaintenance => "Pre-maintenance".to_owned(), } } fn validate_v4_listing_metadata( - layout: &v4::SavedBackupLayout, - metadata: &v4::BackupMetadata, - manifest: &v4::BackupManifest, + layout: &saved_backup::SavedBackupLayout, + metadata: &saved_backup::BackupMetadata, + manifest: &saved_backup::BackupManifest, ) -> Result<()> { if metadata.backup_id != manifest.backup_id { return Err(AppError::BadRequest(format!( @@ -121,7 +121,7 @@ fn validate_v4_listing_metadata( } if !matches!( metadata.storage_mode, - v4::BackupStorageMode::Directory | v4::BackupStorageMode::SplitZip + saved_backup::BackupStorageMode::Directory | saved_backup::BackupStorageMode::SplitZip ) { return Err(AppError::BadRequest(format!( "Saved backup {} uses unsupported saved-v4 storage mode '{}'.", @@ -170,15 +170,17 @@ fn validate_v4_listing_metadata( } match metadata.storage_mode { - v4::BackupStorageMode::Directory if !manifest.parts.is_empty() => { + saved_backup::BackupStorageMode::Directory if !manifest.parts.is_empty() => { Err(AppError::BadRequest(format!( "Saved backup {} is directory mode but contains split ZIP metadata.", layout.backup_ref ))) } - v4::BackupStorageMode::SplitZip => validate_split_zip_listing_metadata(layout, manifest), - v4::BackupStorageMode::Directory => Ok(()), - v4::BackupStorageMode::SingleZip | v4::BackupStorageMode::LegacyZip => { + saved_backup::BackupStorageMode::SplitZip => { + validate_split_zip_listing_metadata(layout, manifest) + } + saved_backup::BackupStorageMode::Directory => Ok(()), + saved_backup::BackupStorageMode::SingleZip | saved_backup::BackupStorageMode::LegacyZip => { Err(AppError::BadRequest(format!( "Saved backup {} uses unsupported saved-v4 storage mode '{}'.", layout.backup_ref, @@ -189,8 +191,8 @@ fn validate_v4_listing_metadata( } fn validate_split_zip_listing_metadata( - layout: &v4::SavedBackupLayout, - manifest: &v4::BackupManifest, + layout: &saved_backup::SavedBackupLayout, + manifest: &saved_backup::BackupManifest, ) -> Result<()> { if manifest.parts.is_empty() { return Err(AppError::BadRequest(format!( @@ -265,7 +267,7 @@ fn validate_split_zip_listing_metadata( layout.backup_ref, entry.logical_path ))); } - v4::sanitize_logical_path(&entry.logical_path)?; + saved_backup::sanitize_logical_path(&entry.logical_path)?; let Some(part_filename) = entry.zip_part.as_deref() else { continue; }; @@ -279,18 +281,18 @@ fn validate_split_zip_listing_metadata( .zip_entry_path .as_deref() .unwrap_or(&entry.logical_path); - v4::sanitize_logical_path(entry_path)?; + saved_backup::sanitize_logical_path(entry_path)?; } Ok(()) } fn list_v4_backups(kind: BackupListKind) -> Vec { let mut backups = Vec::new(); - for layout in v4::iter_saved_backup_layouts() { - let Ok(metadata) = v4::load_metadata(&layout.metadata_path) else { + for layout in saved_backup::iter_saved_backup_layouts() { + let Ok(metadata) = saved_backup::load_metadata(&layout.metadata_path) else { continue; }; - let Ok(manifest) = v4::load_manifest(&layout.manifest_path) else { + let Ok(manifest) = saved_backup::load_manifest(&layout.manifest_path) else { continue; }; let listing_validation = validate_v4_listing_metadata(&layout, &metadata, &manifest); @@ -342,7 +344,8 @@ fn list_v4_backups(kind: BackupListKind) -> Vec { boards: metadata.included_boards.clone(), server_path: layout.root_dir.display().to_string(), manifest_path: layout.manifest_path.display().to_string(), - downloadable_archive: metadata.storage_mode == v4::BackupStorageMode::SingleZip, + downloadable_archive: metadata.storage_mode + == saved_backup::BackupStorageMode::SingleZip, }); let _ = manifest; @@ -467,7 +470,7 @@ pub fn list_backup_files(dir: &std::path::Path, kind: BackupListKind) -> Vec Result<()> { - let backup_root = v4::backups_root_dir(); + let backup_root = saved_backup::backups_root_dir(); crate::utils::fs_security::assert_dir_no_symlink(path).map_err(|error| { AppError::BadRequest(format!( "Saved backup directory {} is unsafe to delete: {error}", @@ -574,7 +577,7 @@ mod tests { #[test] fn safe_saved_backup_dir_for_delete_rejects_paths_outside_backup_root() { - let backup_root = v4::backups_root_dir(); + let backup_root = saved_backup::backups_root_dir(); std::fs::create_dir_all(&backup_root).expect("backup root"); let data_dir = backup_root.parent().expect("backup root has parent"); let outside = tempfile::Builder::new() diff --git a/src/handlers/admin/backup/restore_full.rs b/src/handlers/admin/backup/restore_full.rs index a52d358d..8db09af0 100644 --- a/src/handlers/admin/backup/restore_full.rs +++ b/src/handlers/admin/backup/restore_full.rs @@ -542,6 +542,12 @@ pub(super) fn execute_full_restore( let backup_result = (|| -> Result<()> { let src = rusqlite::Connection::open(&temp_db) .map_err(|error| AppError::Internal(anyhow::anyhow!("Open backup source: {error}")))?; + db::normalize_database_schema_version(&src).map_err(|error| { + AppError::BadRequest(format!( + "Restored database does not match the RustChan {} baseline: {error}", + db::baseline_schema_version() + )) + })?; validate_full_restore_db_trust_boundary(&src)?; scrub_full_restore_runtime_state(&src)?; db::rebuild_pending_fs_ops_for_restore(&src)?; diff --git a/src/handlers/admin/backup/v4.rs b/src/handlers/admin/backup/saved_backup.rs similarity index 98% rename from src/handlers/admin/backup/v4.rs rename to src/handlers/admin/backup/saved_backup.rs index 235773f1..9c58e97e 100644 --- a/src/handlers/admin/backup/v4.rs +++ b/src/handlers/admin/backup/saved_backup.rs @@ -1,3 +1,5 @@ +//! Saved Backup v4 format definitions, filesystem layout helpers, and strict verification. + use crate::error::{AppError, Result}; use serde::{Deserialize, Serialize}; use sha2::{Digest as _, Sha256}; @@ -1543,6 +1545,9 @@ pub(crate) fn verify_saved_v4_root( } None => None, }; + if let Some(snapshot) = &db_snapshot { + verify_db_snapshot_schema(snapshot)?; + } match manifest.scope { BackupScope::FullSite | BackupScope::PreMaintenance => { @@ -1800,6 +1805,38 @@ pub(crate) fn verify_saved_v4_root( }) } +fn verify_db_snapshot_schema(snapshot: &VerifiedSavedV4DbSnapshot) -> Result<()> { + let mut temp_db = tempfile::NamedTempFile::new().map_err(|error| { + AppError::Internal(anyhow::anyhow!( + "Create temporary Backup v4 DB validation file: {error}" + )) + })?; + copy_verified_file_to_writer(&snapshot.file, temp_db.as_file_mut())?; + let conn = rusqlite::Connection::open(temp_db.path()).map_err(|error| { + AppError::BadRequest(format!( + "Backup v4 DB snapshot could not be opened as SQLite: {error}" + )) + })?; + crate::db::normalize_database_schema_version(&conn).map_err(|error| { + AppError::BadRequest(format!( + "Backup v4 DB snapshot does not match the RustChan {} database baseline: {error}", + crate::db::baseline_schema_version() + )) + }) +} + +#[cfg(test)] +pub(crate) fn valid_db_snapshot_for_test() -> Vec { + let temp_dir = tempfile::tempdir().expect("tempdir"); + let db_path = temp_dir.path().join("snapshot.sqlite3"); + let pool = crate::db::init_test_pool().expect("test pool"); + let conn = pool.get().expect("db conn"); + let db_path_sql = db_path.to_str().expect("db path").replace('\'', "''"); + conn.execute_batch(&format!("VACUUM INTO '{db_path_sql}'")) + .expect("vacuum snapshot"); + std::fs::read(db_path).expect("read db snapshot") +} + #[cfg(test)] pub(crate) fn write_saved_v4_fixture_for_test( root_dir: &Path, @@ -1980,7 +2017,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_000_i64, ); (dir, root, manifest) @@ -2144,7 +2181,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_000_i64, ); @@ -2253,7 +2290,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_100_i64, ); std::fs::write(root.join("boards/tech/media/src/example.txt"), b"other") @@ -2273,7 +2310,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_200_i64, ); std::fs::remove_file(root.join("boards/tech/media/src/example.txt")).expect("remove file"); @@ -2291,7 +2328,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_250_i64, ); convert_fixture_to_split(&root, 16); @@ -2529,7 +2566,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_300_i64, ); manifest.files.push( @@ -2558,7 +2595,7 @@ mod tests { &root, BackupScope::FullSite, board_fixture_files_for_test(), - Some(b"sqlite".to_vec()), + Some(valid_db_snapshot_for_test()), 1_715_000_400_i64, ); let outside_dir = dir.path().join("outside"); diff --git a/src/handlers/admin/content.rs b/src/handlers/admin/content.rs index 97b7dc87..74b845b8 100644 --- a/src/handlers/admin/content.rs +++ b/src/handlers/admin/content.rs @@ -775,6 +775,7 @@ mod tests { max_image_size: 8 * 1024 * 1024, max_video_size: 50 * 1024 * 1024, max_audio_size: 150 * 1024 * 1024, + max_pdf_size: 8 * 1024 * 1024, allow_pdf: false, allow_any_files: false, allow_tripcodes: true, diff --git a/src/handlers/admin/mod.rs b/src/handlers/admin/mod.rs index eeceeedf..6337a68a 100644 --- a/src/handlers/admin/mod.rs +++ b/src/handlers/admin/mod.rs @@ -98,7 +98,10 @@ fn require_admin_session_with_name( /// Check CSRF using the cookie jar. Returns error on mismatch. /// Verify admin session from a session ID string. /// For use inside `spawn_blocking` closures where we have an open connection. -fn require_admin_session_sid(conn: &rusqlite::Connection, session_id: Option<&str>) -> Result { +pub(in crate::handlers) fn require_admin_session_sid( + conn: &rusqlite::Connection, + session_id: Option<&str>, +) -> Result { let sid = session_id.ok_or_else(|| AppError::Forbidden("Not logged in.".into()))?; let session = db::get_session(conn, sid)? .ok_or_else(|| AppError::Forbidden("Session expired or invalid.".into()))?; @@ -114,15 +117,7 @@ pub(super) fn require_same_origin_request( .and_then(|value| value.to_str().ok()) .and_then(|value| value.parse::().ok()) .ok_or_else(|| AppError::Forbidden("Missing Host header.".into()))?; - let request_scheme = - if crate::middleware::forwarded_proto_is_https(headers, peer, CONFIG.behind_proxy) - || (CONFIG.tls.enabled && host_header_uses_https_port(headers)) - || request_origin_uses_https(headers) - { - "https" - } else { - "http" - }; + let request_scheme = request_scheme_for_same_origin(headers, peer, request_authority.host()); let request_port = request_authority .port_u16() .unwrap_or(if request_scheme == "https" { 443 } else { 80 }); @@ -236,7 +231,7 @@ pub(super) fn admin_csrf_is_valid(jar: &CookieJar, form_token: Option<&str>) -> validate_signed_csrf(csrf_cookie, session_id, form_token.unwrap_or("")) } -pub(super) fn require_same_origin_or_valid_csrf( +pub(in crate::handlers) fn require_same_origin_or_valid_csrf( headers: &HeaderMap, peer: Option, csrf_valid: bool, @@ -254,7 +249,7 @@ pub(super) fn require_same_origin_or_valid_csrf( } } -pub(super) fn require_admin_post_origin_and_csrf( +pub(in crate::handlers) fn require_admin_post_origin_and_csrf( jar: &CookieJar, headers: &HeaderMap, peer: Option, @@ -333,7 +328,42 @@ fn should_set_secure_cookie_with_config( || crate::middleware::forwarded_proto_is_https(headers, context.peer, behind_proxy)) } -fn host_header_uses_https_port(headers: &HeaderMap) -> bool { +fn request_scheme_for_same_origin( + headers: &HeaderMap, + peer: Option, + request_host: &str, +) -> &'static str { + request_scheme_for_same_origin_with_config( + headers, + peer, + request_host, + CONFIG.behind_proxy, + CONFIG.tls.enabled, + CONFIG.tls.port, + ) +} + +fn request_scheme_for_same_origin_with_config( + headers: &HeaderMap, + peer: Option, + request_host: &str, + behind_proxy: bool, + tls_enabled: bool, + tls_port: u16, +) -> &'static str { + if crate::middleware::forwarded_proto_is_https(headers, peer, behind_proxy) + || request_origin_uses_https(headers) + || (tls_enabled + && !is_onion_host(request_host) + && host_header_uses_https_port_with_config(headers, tls_port)) + { + "https" + } else { + "http" + } +} + +fn host_header_uses_https_port_with_config(headers: &HeaderMap, tls_port: u16) -> bool { let Some(host) = headers .get(header::HOST) .and_then(|value| value.to_str().ok()) @@ -346,8 +376,8 @@ fn host_header_uses_https_port(headers: &HeaderMap) -> bool { }; match authority.port_u16() { - Some(port) => port == CONFIG.tls.port, - None => CONFIG.tls.port == 443, + Some(port) => port == tls_port, + None => tls_port == 443, } } @@ -406,6 +436,15 @@ fn normalize_same_origin_host(host: &str) -> &str { } } +fn is_onion_host(host: &str) -> bool { + let host = normalize_same_origin_host(host); + let Some((label, suffix)) = host.rsplit_once('.') else { + return false; + }; + + !label.is_empty() && suffix.eq_ignore_ascii_case("onion") +} + fn is_loopback_alias(host: &str) -> bool { let host = normalize_same_origin_host(host); @@ -571,6 +610,7 @@ struct AdminPanelSnapshot { board_backups: Vec, db_size_bytes: i64, db_size_warning: bool, + setup_status: crate::templates::AdminPanelSetupStatus, ffmpeg_timeout_secs: u64, media_auto_prune_enabled: bool, media_max_active_content_size_bytes: u64, @@ -583,6 +623,7 @@ struct AdminPanelSnapshot { pdf_thumbnail_renderer: Option, backup_summary: BackupSummary, site_health: SiteHealthSnapshot, + dashboard: AdminDashboardSummary, } #[derive(Clone)] @@ -595,14 +636,88 @@ struct OverviewDomainData { backup_summary: BackupSummary, } +#[derive(Clone)] +struct AdminDashboardSummary { + version: String, + build: String, + setup_status: String, + setup_detail: String, + setup_state: crate::templates::AdminDashboardState, + site_title: String, + public_url: String, + db_status: String, + db_detail: String, + db_state: crate::templates::AdminDashboardState, + backup_status: String, + backup_detail: String, + backup_state: crate::templates::AdminDashboardState, + storage_status: String, + storage_detail: String, + storage_state: crate::templates::AdminDashboardState, + tor_status: String, + tor_detail: String, + tor_state: crate::templates::AdminDashboardState, + dependency_status: String, + dependency_detail: String, + dependency_state: crate::templates::AdminDashboardState, + job_status: String, + job_detail: String, + job_state: crate::templates::AdminDashboardState, + board_count: String, + thread_count: String, + post_count: String, + recent_activity: String, + media_summary: String, + report_status: String, + report_detail: String, + report_state: crate::templates::AdminDashboardState, +} + +#[derive(Clone, Debug, Default, PartialEq, Eq)] +struct DashboardActivitySnapshot { + board_count: usize, + active_threads: Option, + total_threads: Option, + total_posts: Option, + posts_24h: Option, + posts_7d: Option, + upload_posts: Option, + total_images: Option, + total_videos: Option, + total_audio: Option, + active_bytes: Option, + recent_reports_7d: Option, +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +struct DashboardThreadCounts { + active: i64, + total: i64, +} + +struct DashboardSummaryInputs<'a> { + activity: &'a DashboardActivitySnapshot, + moderation: &'a ModerationDomainData, + appearance: &'a AppearanceDomainData, + backup_summary: &'a BackupSummary, + maintenance: &'a MaintenanceDomainData, + setup_status: crate::templates::AdminPanelSetupStatus, + site_health: &'a SiteHealthSnapshot, + tor_address: Option<&'a str>, +} + struct SiteHealthSnapshot { server_status: String, + database_schema_status: String, database_integrity_status: String, last_successful_backup: String, next_scheduled_backup: String, data_dir_usage: String, upload_dir_size: String, tor_status: String, + tor_service_status: String, + tor_mode: String, + tor_config_summary: String, running_jobs: i64, queued_jobs: i64, recent_completed_jobs: i64, @@ -706,7 +821,7 @@ fn load_site_health_snapshot( state: &AppState, full_backups: &[BackupInfo], auto_full_backup_settings: &crate::middleware::AutoFullBackupSettingsSnapshot, - _onion_address_val: Option<&str>, + onion_address_val: Option<&str>, ) -> SiteHealthSnapshot { let server_status = conn .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) @@ -714,6 +829,7 @@ fn load_site_health_snapshot( .filter(|value| *value == 1) .map_or_else(|| "degraded".to_owned(), |_| "ready".to_owned()); let database_integrity_status = db_integrity_status(&state.db_maintenance_jobs.snapshot()); + let database_schema_status = db::database_schema_status_label(conn); let last_successful_backup = full_backups .iter() .find(|backup| backup.verified) @@ -724,9 +840,16 @@ fn load_site_health_snapshot( let upload_dir_size = safe_dir_size_label(Path::new(&CONFIG.upload_dir)); let jobs = load_site_health_jobs_snapshot(conn, state); let recent_warnings = recent_warning_lines().unwrap_or_else(|| "not available".to_owned()); + let tor_service_status = tor_service_status_label(onion_address_val); + let tor_mode = tor_mode_label(); + let tor_config_summary = format!( + "bootstrap timeout {}s; max streams {}", + CONFIG.tor_bootstrap_timeout_secs, CONFIG.tor_max_concurrent_streams + ); SiteHealthSnapshot { server_status, + database_schema_status, database_integrity_status, last_successful_backup, next_scheduled_backup, @@ -735,8 +858,11 @@ fn load_site_health_snapshot( tor_status: if CONFIG.enable_tor_support { "enabled".to_owned() } else { - "disabled".to_owned() + "disabled; set enable_tor_support = true in settings.toml, then restart".to_owned() }, + tor_service_status, + tor_mode, + tor_config_summary, running_jobs: jobs.running, queued_jobs: jobs.queued, recent_completed_jobs: jobs.recent_completed, @@ -747,6 +873,90 @@ fn load_site_health_snapshot( } } +fn tor_service_status_label(onion_address: Option<&str>) -> String { + if !CONFIG.enable_tor_support { + "not started; enable Tor support in settings.toml and restart".to_owned() + } else if onion_address.is_some() { + "onion service ready".to_owned() + } else { + "starting or unavailable; check logs if bootstrap does not finish".to_owned() + } +} + +fn tor_mode_label() -> String { + if !CONFIG.enable_tor_support { + "clearnet only".to_owned() + } else if CONFIG.tor_only { + "Tor only".to_owned() + } else { + "clearnet and Tor".to_owned() + } +} + +fn load_dashboard_activity_snapshot( + conn: &rusqlite::Connection, + board_count: usize, +) -> DashboardActivitySnapshot { + let site_stats = db::get_site_stats(conn).ok(); + let thread_counts = dashboard_thread_counts(conn); + + DashboardActivitySnapshot { + board_count, + active_threads: thread_counts.map(|counts| counts.active), + total_threads: thread_counts.map(|counts| counts.total), + total_posts: site_stats.as_ref().map(|stats| stats.total_posts), + posts_24h: dashboard_recent_count(conn, "posts", 24 * 60 * 60), + posts_7d: dashboard_recent_count(conn, "posts", 7 * 24 * 60 * 60), + upload_posts: optional_count_query( + conn, + "SELECT COUNT(*) FROM posts + WHERE file_path IS NOT NULL OR audio_file_path IS NOT NULL", + ), + total_images: site_stats.as_ref().map(|stats| stats.total_images), + total_videos: site_stats.as_ref().map(|stats| stats.total_videos), + total_audio: site_stats.as_ref().map(|stats| stats.total_audio), + active_bytes: site_stats.map(|stats| stats.active_bytes), + recent_reports_7d: dashboard_recent_count(conn, "reports", 7 * 24 * 60 * 60), + } +} + +fn dashboard_thread_counts(conn: &rusqlite::Connection) -> Option { + conn.query_row( + "SELECT + COALESCE(SUM(CASE WHEN archived = 0 THEN 1 ELSE 0 END), 0), + COUNT(*) + FROM threads", + [], + |row| { + Ok(DashboardThreadCounts { + active: row.get(0)?, + total: row.get(1)?, + }) + }, + ) + .ok() +} + +fn dashboard_recent_count( + conn: &rusqlite::Connection, + table_name: &str, + window_secs: i64, +) -> Option { + if !matches!(table_name, "posts" | "reports") { + return None; + } + conn.query_row( + &format!("SELECT COUNT(*) FROM {table_name} WHERE created_at >= unixepoch() - ?1"), + rusqlite::params![window_secs.max(0)], + |row| row.get(0), + ) + .ok() +} + +fn optional_count_query(conn: &rusqlite::Connection, query: &str) -> Option { + conn.query_row(query, [], |row| row.get(0)).ok() +} + fn load_site_health_jobs_snapshot( conn: &rusqlite::Connection, state: &AppState, @@ -1096,6 +1306,8 @@ fn load_admin_panel_snapshot( let backups_domain = load_backups_domain_data(); let overview_domain = load_overview_domain_data(&backups_domain.full_backups); let maintenance_domain = load_maintenance_domain_data(conn, state); + let setup_state = db::setup_state(conn)?; + let setup_status = admin_panel_setup_status(setup_state); let site_health = load_site_health_snapshot( conn, state, @@ -1103,6 +1315,17 @@ fn load_admin_panel_snapshot( &auto_full_backup_settings, onion_address_val.as_deref(), ); + let dashboard_activity = load_dashboard_activity_snapshot(conn, boards_domain.boards.len()); + let dashboard = build_admin_dashboard_summary(DashboardSummaryInputs { + activity: &dashboard_activity, + moderation: &moderation_domain, + appearance: &appearance_domain, + backup_summary: &overview_domain.backup_summary, + maintenance: &maintenance_domain, + setup_status, + site_health: &site_health, + tor_address: onion_address_val.as_deref(), + }); Ok(( AdminPanelSnapshot { boards: boards_domain.boards, @@ -1134,6 +1357,7 @@ fn load_admin_panel_snapshot( board_backups: backups_domain.board_backups, db_size_bytes: maintenance_domain.db_size_bytes, db_size_warning: maintenance_domain.db_size_warning, + setup_status, ffmpeg_timeout_secs: maintenance_domain.ffmpeg_timeout_secs, media_auto_prune_enabled: maintenance_domain.media_auto_prune_enabled, media_max_active_content_size_bytes: maintenance_domain @@ -1147,11 +1371,26 @@ fn load_admin_panel_snapshot( pdf_thumbnail_renderer: maintenance_domain.pdf_thumbnail_renderer, backup_summary: overview_domain.backup_summary, site_health, + dashboard, }, onion_address_val, )) } +const fn admin_panel_setup_status( + setup_state: db::SetupState, +) -> crate::templates::AdminPanelSetupStatus { + if setup_state.reopened { + crate::templates::AdminPanelSetupStatus::Reopened + } else if setup_state.completed { + crate::templates::AdminPanelSetupStatus::Complete + } else if setup_state.is_available() { + crate::templates::AdminPanelSetupStatus::Available + } else { + crate::templates::AdminPanelSetupStatus::Initialized + } +} + fn build_backup_summary(full_backups: &[BackupInfo]) -> BackupSummary { const BACKUP_WARN_AFTER_HOURS: i64 = 72; @@ -1196,6 +1435,354 @@ fn build_backup_summary(full_backups: &[BackupInfo]) -> BackupSummary { } } +fn build_admin_dashboard_summary(inputs: DashboardSummaryInputs<'_>) -> AdminDashboardSummary { + let (setup_status, setup_detail, setup_state) = dashboard_setup_status(inputs.setup_status); + let (db_status, db_detail, db_state) = dashboard_database_status(inputs.site_health); + let (backup_status, backup_detail, backup_state) = + dashboard_backup_status(inputs.backup_summary); + let (storage_status, storage_detail, storage_state) = + dashboard_storage_status(inputs.activity, inputs.maintenance, inputs.site_health); + let (tor_status, tor_detail, tor_state) = dashboard_tor_status(inputs.tor_address); + let (dependency_status, dependency_detail, dependency_state) = + dashboard_dependency_status(inputs.maintenance); + let (job_status, job_detail, job_state) = dashboard_job_status(inputs.site_health); + let (report_status, report_detail, report_state) = + dashboard_report_status(inputs.activity, inputs.moderation); + + AdminDashboardSummary { + version: env!("CARGO_PKG_VERSION").to_owned(), + build: format!("{}/{}", std::env::consts::OS, std::env::consts::ARCH), + setup_status, + setup_detail, + setup_state, + site_title: inputs.appearance.site_name.clone(), + public_url: public_url_label(), + db_status, + db_detail, + db_state, + backup_status, + backup_detail, + backup_state, + storage_status, + storage_detail, + storage_state, + tor_status, + tor_detail, + tor_state, + dependency_status, + dependency_detail, + dependency_state, + job_status, + job_detail, + job_state, + board_count: count_label(inputs.activity.board_count, "board", "boards"), + thread_count: thread_count_label(inputs.activity), + post_count: optional_count_label(inputs.activity.total_posts, "post", "posts"), + recent_activity: recent_activity_label(inputs.activity), + media_summary: media_summary_label(inputs.activity), + report_status, + report_detail, + report_state, + } +} + +fn dashboard_setup_status( + status: crate::templates::AdminPanelSetupStatus, +) -> (String, String, crate::templates::AdminDashboardState) { + match status { + crate::templates::AdminPanelSetupStatus::Reopened => ( + "reopened".to_owned(), + "Setup wizard is admin-only and currently reopened.".to_owned(), + crate::templates::AdminDashboardState::Warning, + ), + crate::templates::AdminPanelSetupStatus::Complete => ( + "complete".to_owned(), + "Public setup routes are blocked.".to_owned(), + crate::templates::AdminDashboardState::Ok, + ), + crate::templates::AdminPanelSetupStatus::Available => ( + "available".to_owned(), + "First-run setup still needs to be completed.".to_owned(), + crate::templates::AdminDashboardState::ActionNeeded, + ), + crate::templates::AdminPanelSetupStatus::Initialized => ( + "initialized".to_owned(), + "Durable runtime state exists and public setup is blocked.".to_owned(), + crate::templates::AdminDashboardState::Ok, + ), + } +} + +fn dashboard_database_status( + site_health: &SiteHealthSnapshot, +) -> (String, String, crate::templates::AdminDashboardState) { + let state = if site_health.server_status != "ready" + || site_health.database_schema_status.contains("mismatch") + || site_health.database_integrity_status.contains("failed") + { + crate::templates::AdminDashboardState::ActionNeeded + } else if site_health.database_integrity_status.contains("running") { + crate::templates::AdminDashboardState::Warning + } else if site_health.database_integrity_status == "not checked" { + crate::templates::AdminDashboardState::Unknown + } else { + crate::templates::AdminDashboardState::Ok + }; + ( + site_health.server_status.clone(), + format!( + "Schema: {}; integrity: {}.", + site_health.database_schema_status, site_health.database_integrity_status + ), + state, + ) +} + +fn dashboard_backup_status( + backup_summary: &BackupSummary, +) -> (String, String, crate::templates::AdminDashboardState) { + let Some(warning) = backup_summary.warning.as_deref() else { + return ( + "current".to_owned(), + backup_summary.status_line.clone(), + crate::templates::AdminDashboardState::Ok, + ); + }; + + let (status, state) = if warning.starts_with("No saved full backup") { + ( + "no full backup", + crate::templates::AdminDashboardState::ActionNeeded, + ) + } else if warning.contains("failed verification") { + ( + "verification failed", + crate::templates::AdminDashboardState::ActionNeeded, + ) + } else { + ( + "stale backup", + crate::templates::AdminDashboardState::Warning, + ) + }; + (status.to_owned(), warning.to_owned(), state) +} + +fn dashboard_storage_status( + activity: &DashboardActivitySnapshot, + maintenance: &MaintenanceDomainData, + site_health: &SiteHealthSnapshot, +) -> (String, String, crate::templates::AdminDashboardState) { + let active_media = activity.active_bytes.map_or_else( + || "unknown".to_owned(), + crate::utils::files::format_file_size, + ); + let storage_known = + site_health.data_dir_usage != "unknown" && site_health.upload_dir_size != "unknown"; + let over_prune_limit = activity.active_bytes.is_some_and(|bytes| { + maintenance.media_max_active_content_size_bytes > 0 + && u64::try_from(bytes) + .is_ok_and(|bytes| bytes >= maintenance.media_max_active_content_size_bytes) + }); + let state = if over_prune_limit { + crate::templates::AdminDashboardState::Warning + } else if storage_known { + crate::templates::AdminDashboardState::Ok + } else { + crate::templates::AdminDashboardState::Unknown + }; + let status = if over_prune_limit { + "above prune threshold".to_owned() + } else { + format!("uploads {}", site_health.upload_dir_size) + }; + ( + status, + format!( + "Data directory {}; active media {}.", + site_health.data_dir_usage, active_media + ), + state, + ) +} + +fn dashboard_tor_status( + tor_address: Option<&str>, +) -> (String, String, crate::templates::AdminDashboardState) { + if !CONFIG.enable_tor_support { + return ( + "disabled".to_owned(), + "Set enable_tor_support = true in settings.toml, then restart RustChan.".to_owned(), + crate::templates::AdminDashboardState::Disabled, + ); + } + if tor_address.is_some() { + ( + "onion ready".to_owned(), + "Tor support is enabled and an onion address is available.".to_owned(), + crate::templates::AdminDashboardState::Ok, + ) + } else { + ( + "enabled, address pending".to_owned(), + "Wait for bootstrap, or check Site Health Tor diagnostics if no onion appears." + .to_owned(), + crate::templates::AdminDashboardState::Warning, + ) + } +} + +fn dashboard_dependency_status( + maintenance: &MaintenanceDomainData, +) -> (String, String, crate::templates::AdminDashboardState) { + let ffmpeg = detection_word(maintenance.ffmpeg_available); + let ffprobe = detection_word(maintenance.ffprobe_available); + let state = if maintenance.ffmpeg_available && maintenance.ffprobe_available { + crate::templates::AdminDashboardState::Ok + } else if CONFIG.require_ffmpeg { + crate::templates::AdminDashboardState::ActionNeeded + } else { + crate::templates::AdminDashboardState::Warning + }; + let status = if maintenance.ffmpeg_available && maintenance.ffprobe_available { + "ready" + } else if CONFIG.require_ffmpeg { + "required tool missing" + } else { + "limited" + }; + ( + status.to_owned(), + format!( + "ffmpeg {ffmpeg}; ffprobe {ffprobe}; WebP {}; VP9 {}; Opus {}.", + detection_word(maintenance.ffmpeg_webp_available), + detection_word(maintenance.ffmpeg_vp9_encoder_available), + detection_word(maintenance.ffmpeg_opus_available) + ), + state, + ) +} + +fn dashboard_job_status( + site_health: &SiteHealthSnapshot, +) -> (String, String, crate::templates::AdminDashboardState) { + let state = if site_health.failed_jobs > 0 { + crate::templates::AdminDashboardState::ActionNeeded + } else if site_health.running_jobs > 0 + || site_health.queued_jobs > 0 + || site_health.backup_jobs != "idle" + { + crate::templates::AdminDashboardState::Warning + } else if site_health.backup_jobs == "unknown" { + crate::templates::AdminDashboardState::Unknown + } else { + crate::templates::AdminDashboardState::Ok + }; + let status = if site_health.failed_jobs > 0 { + format!("{} failed", site_health.failed_jobs) + } else if site_health.running_jobs > 0 || site_health.queued_jobs > 0 { + format!( + "{} running / {} queued", + site_health.running_jobs, site_health.queued_jobs + ) + } else { + "idle".to_owned() + }; + ( + status, + format!( + "Recently completed {}; backup job {}; restore jobs {}.", + site_health.recent_completed_jobs, site_health.backup_jobs, site_health.restore_jobs + ), + state, + ) +} + +fn dashboard_report_status( + activity: &DashboardActivitySnapshot, + moderation: &ModerationDomainData, +) -> (String, String, crate::templates::AdminDashboardState) { + let open_reports = moderation.reports.len(); + let open_appeals = moderation.appeals.len(); + let recent_reports = activity.recent_reports_7d; + let state = if open_reports > 0 || open_appeals > 0 { + crate::templates::AdminDashboardState::ActionNeeded + } else if recent_reports.is_some_and(|count| count > 0) { + crate::templates::AdminDashboardState::Warning + } else if recent_reports.is_some() { + crate::templates::AdminDashboardState::Ok + } else { + crate::templates::AdminDashboardState::Unknown + }; + let status = if open_reports == 0 { + "no open reports".to_owned() + } else { + count_label(open_reports, "open report", "open reports") + }; + let recent = recent_reports.map_or_else(|| "unknown".to_owned(), |count| count.to_string()); + ( + status, + format!("{recent} reports in 7d; {open_appeals} open appeals."), + state, + ) +} + +fn public_url_label() -> String { + let Some(host) = CONFIG.public_hosts.first().filter(|host| !host.is_empty()) else { + return "not configured".to_owned(); + }; + let scheme = if CONFIG.tls.enabled { "https" } else { "http" }; + format!("{scheme}://{host}") +} + +fn count_label(count: usize, singular: &str, plural: &str) -> String { + if count == 1 { + format!("1 {singular}") + } else { + format!("{count} {plural}") + } +} + +fn optional_count_label(count: Option, singular: &str, plural: &str) -> String { + match count { + Some(1) => format!("1 {singular}"), + Some(count) => format!("{count} {plural}"), + None => "unknown".to_owned(), + } +} + +fn thread_count_label(activity: &DashboardActivitySnapshot) -> String { + match (activity.active_threads, activity.total_threads) { + (Some(active), Some(total)) => format!("{active} active / {total} total"), + _ => "unknown".to_owned(), + } +} + +fn recent_activity_label(activity: &DashboardActivitySnapshot) -> String { + match (activity.posts_24h, activity.posts_7d) { + (Some(day), Some(week)) => format!("{day} posts in 24h; {week} in 7d"), + _ => "recent post activity unknown".to_owned(), + } +} + +fn media_summary_label(activity: &DashboardActivitySnapshot) -> String { + match ( + activity.upload_posts, + activity.total_images, + activity.total_videos, + activity.total_audio, + activity.active_bytes, + ) { + (Some(upload_posts), Some(images), Some(videos), Some(audio), Some(active_bytes)) => { + let active = crate::utils::files::format_file_size(active_bytes); + format!( + "{upload_posts} upload posts; {images} images, {videos} video, {audio} audio; {active} active" + ) + } + _ => "media summary unknown".to_owned(), + } +} + fn render_admin_panel_from_snapshot( snapshot: AdminPanelSnapshot, csrf_token: &str, @@ -1215,6 +1802,7 @@ fn render_admin_panel_from_snapshot( csrf_token, boards: &snapshot.boards, current_theme, + dashboard: build_dashboard_view(&snapshot.dashboard), moderation: crate::templates::AdminPanelModerationView { bans: &snapshot.bans, filters: &snapshot.filters, @@ -1256,6 +1844,7 @@ fn render_admin_panel_from_snapshot( maintenance: crate::templates::AdminPanelMaintenanceView { db_size_bytes: snapshot.db_size_bytes, db_size_warning: snapshot.db_size_warning, + setup_status: snapshot.setup_status, ffmpeg_timeout_secs: snapshot.ffmpeg_timeout_secs, media_auto_prune_enabled: snapshot.media_auto_prune_enabled, media_max_active_content_size_bytes: snapshot.media_max_active_content_size_bytes, @@ -1290,6 +1879,46 @@ fn render_admin_panel_from_snapshot( crate::templates::admin_panel_page(&view) } +fn build_dashboard_view( + dashboard: &AdminDashboardSummary, +) -> crate::templates::AdminPanelDashboardView<'_> { + crate::templates::AdminPanelDashboardView { + version: &dashboard.version, + build: &dashboard.build, + setup_status: &dashboard.setup_status, + setup_detail: &dashboard.setup_detail, + setup_state: dashboard.setup_state, + site_title: &dashboard.site_title, + public_url: &dashboard.public_url, + db_status: &dashboard.db_status, + db_detail: &dashboard.db_detail, + db_state: dashboard.db_state, + backup_status: &dashboard.backup_status, + backup_detail: &dashboard.backup_detail, + backup_state: dashboard.backup_state, + storage_status: &dashboard.storage_status, + storage_detail: &dashboard.storage_detail, + storage_state: dashboard.storage_state, + tor_status: &dashboard.tor_status, + tor_detail: &dashboard.tor_detail, + tor_state: dashboard.tor_state, + dependency_status: &dashboard.dependency_status, + dependency_detail: &dashboard.dependency_detail, + dependency_state: dashboard.dependency_state, + job_status: &dashboard.job_status, + job_detail: &dashboard.job_detail, + job_state: dashboard.job_state, + board_count: &dashboard.board_count, + thread_count: &dashboard.thread_count, + post_count: &dashboard.post_count, + recent_activity: &dashboard.recent_activity, + media_summary: &dashboard.media_summary, + report_status: &dashboard.report_status, + report_detail: &dashboard.report_detail, + report_state: dashboard.report_state, + } +} + fn build_site_health_view<'a>( snapshot: &'a AdminPanelSnapshot, tor_address: Option<&'a str>, @@ -1298,6 +1927,7 @@ fn build_site_health_view<'a>( crate::templates::AdminPanelSiteHealthView { server_status: &snapshot.site_health.server_status, rustchan_version: env!("CARGO_PKG_VERSION"), + database_schema_status: &snapshot.site_health.database_schema_status, database_integrity_status: &snapshot.site_health.database_integrity_status, last_successful_backup: &snapshot.site_health.last_successful_backup, next_scheduled_backup: &snapshot.site_health.next_scheduled_backup, @@ -1305,6 +1935,10 @@ fn build_site_health_view<'a>( upload_dir_size: &snapshot.site_health.upload_dir_size, tor_status: &snapshot.site_health.tor_status, tor_onion_address: tor_address, + tor_service_status: &snapshot.site_health.tor_service_status, + tor_mode: &snapshot.site_health.tor_mode, + tor_config_summary: &snapshot.site_health.tor_config_summary, + tor_detail: &snapshot.dashboard.tor_detail, dependency_summary: crate::templates::AdminSiteHealthDependencySummary { ffmpeg: detection_status(snapshot.ffmpeg_available), ffprobe: detection_status(snapshot.ffprobe_available), @@ -1349,6 +1983,7 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st let tor_detail = tor_address.unwrap_or("not available"); format!( "RustChan version: {version}\n\ + Database schema: {schema}\n\ OS: {os}-{arch}\n\ SQLite: {sqlite}\n\ ffmpeg: {ffmpeg}\n\ @@ -1356,19 +1991,17 @@ fn build_diagnostics_text(snapshot: &AdminPanelSnapshot, tor_address: Option<&st Tor enabled: {tor_enabled} ({tor_detail})\n\ TLS enabled: {tls_enabled}\n\ Reverse proxy: {reverse_proxy}\n\ - Data path: {data_path}\n\ - Main log directory: {main_log_dir}\n\ - Dependency log: {dependency_log}\n\ + Data directory: configured\n\ + Main log directory: configured\n\ + Dependency log: configured\n\ Recent warnings:\n{warnings}\n", version = env!("CARGO_PKG_VERSION"), + schema = snapshot.site_health.database_schema_status.as_str(), os = std::env::consts::OS, arch = std::env::consts::ARCH, sqlite = rusqlite::version(), ffmpeg = detection_word(snapshot.ffmpeg_available), ffprobe = detection_word(snapshot.ffprobe_available), - data_path = crate::config::data_dir().display(), - main_log_dir = crate::config::logs_dir().display(), - dependency_log = crate::logging::dependency_log_path(&crate::config::logs_dir()).display(), warnings = indent_diagnostics_block(&snapshot.site_health.recent_warnings), ) } @@ -1514,6 +2147,54 @@ pub async fn admin_site_health_jobs( .into_response()) } +#[derive(Deserialize)] +pub struct DismissFailedJobsForm { + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn dismiss_failed_site_health_jobs( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, + axum::extract::Form(form): axum::extract::Form, +) -> Result { + let session_id = jar.get(SESSION_COOKIE).map(|c| c.value().to_owned()); + require_admin_post_origin_and_csrf(&jar, &headers, Some(peer), form.csrf.as_deref())?; + + let failed_before = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result { + let conn = pool.get()?; + let (admin_id, admin_name) = + require_admin_session_with_name(&conn, session_id.as_deref())?; + let failed_before = db::background_job_summary(&conn)?.failed; + let acknowledged_through = db::acknowledge_failed_background_jobs(&conn)?; + db::log_mod_action( + &conn, + admin_id, + &admin_name, + "dismiss_failed_jobs", + "background_jobs", + None, + "", + &format!("acknowledged failed background jobs through id {acknowledged_through}"), + )?; + Ok(failed_before) + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; + + let message = if failed_before == 0 { + "Failed job counter was already clear." + } else { + "Failed job counter dismissed." + }; + Ok(admin_panel_redirect_anchor_open(message, "site-health", "site-health").into_response()) +} + pub async fn admin_live_log( State(state): State, jar: CookieJar, @@ -1653,20 +2334,151 @@ pub(super) fn consume_admin_session_bootstrap(token: &str) -> Option { mod tests { use super::{ admin_live_log, admin_site_health_jobs, consume_admin_session_bootstrap, - create_admin_session_bootstrap, host_header_uses_https_port, hosts_match_for_same_origin, - latest_log_file, read_log_tail, request_origin_uses_https, + create_admin_session_bootstrap, dashboard_backup_status, dashboard_recent_count, + dashboard_thread_counts, dismiss_failed_site_health_jobs, + host_header_uses_https_port_with_config, hosts_match_for_same_origin, latest_log_file, + load_dashboard_activity_snapshot, optional_count_query, read_log_tail, + request_origin_uses_https, request_scheme_for_same_origin_with_config, require_same_origin_or_valid_csrf, require_same_origin_request, - should_set_secure_cookie_with_config, LiveLogQuery, SESSION_COOKIE, + should_set_secure_cookie_with_config, BackupSummary, DismissFailedJobsForm, LiveLogQuery, + SESSION_COOKIE, }; use crate::error::AppError; use crate::middleware::SecureCookieContext; use axum::{ body::to_bytes, - extract::{Query, State}, + extract::{ConnectInfo, Form, Query, State}, http::{header, HeaderMap, HeaderValue, StatusCode}, }; use axum_extra::extract::cookie::{Cookie, CookieJar}; + const TEST_ONION_HOST: &str = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion"; + const TEST_ONION_ORIGIN: &str = + "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion"; + const TEST_ONION_HTTPS_ORIGIN: &str = + "https://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion"; + + #[test] + fn dashboard_count_helpers_fail_closed_when_schema_is_missing() { + let conn = rusqlite::Connection::open_in_memory().expect("memory db"); + + assert_eq!( + optional_count_query(&conn, "SELECT COUNT(*) FROM posts"), + None + ); + assert_eq!(dashboard_thread_counts(&conn), None); + assert_eq!(dashboard_recent_count(&conn, "posts", 24 * 60 * 60), None); + assert_eq!( + dashboard_recent_count(&conn, "sqlite_master", 24 * 60 * 60), + None + ); + } + + #[test] + fn dashboard_activity_snapshot_counts_existing_systems() { + let pool = crate::db::init_test_pool().expect("test pool"); + let conn = pool.get().expect("db connection"); + let board_id = + crate::db::create_board(&conn, "dash", "Dashboard", "", false).expect("board"); + let now = chrono::Utc::now().timestamp(); + + conn.execute( + "INSERT INTO threads (board_id, subject, created_at, bumped_at, archived) + VALUES (?1, 'active', ?2, ?2, 0)", + rusqlite::params![board_id, now], + ) + .expect("active thread"); + let active_thread_id = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO posts + (thread_id, board_id, body, body_html, file_path, file_name, file_size, + thumb_path, mime_type, media_type, audio_file_path, audio_file_name, + audio_file_size, audio_mime_type, created_at, deletion_token, is_op) + VALUES + (?1, ?2, 'active body', 'active body', 'dash/image.webp', 'image.webp', 11, + 'dash/thumb.webp', 'image/webp', 'image', 'dash/audio.ogg', 'audio.ogg', + 7, 'audio/ogg', ?3, 'delete-active', 1)", + rusqlite::params![active_thread_id, board_id, now], + ) + .expect("active post"); + let active_post_id = conn.last_insert_rowid(); + + conn.execute( + "INSERT INTO threads (board_id, subject, created_at, bumped_at, archived) + VALUES (?1, 'archived', ?2, ?2, 1)", + rusqlite::params![board_id, now - (8 * 24 * 60 * 60)], + ) + .expect("archived thread"); + let archived_thread_id = conn.last_insert_rowid(); + conn.execute( + "INSERT INTO posts + (thread_id, board_id, body, body_html, file_path, file_name, file_size, + thumb_path, mime_type, media_type, created_at, deletion_token, is_op) + VALUES + (?1, ?2, 'archived body', 'archived body', 'dash/video.webm', 'video.webm', + 1000, 'dash/video-thumb.webp', 'video/webm', 'video', ?3, 'delete-archived', 1)", + rusqlite::params![archived_thread_id, board_id, now - (8 * 24 * 60 * 60)], + ) + .expect("archived post"); + + conn.execute( + "INSERT INTO reports (post_id, thread_id, board_id, reason, reporter_hash, created_at) + VALUES (?1, ?2, ?3, 'needs review', 'reporter', ?4)", + rusqlite::params![active_post_id, active_thread_id, board_id, now], + ) + .expect("report"); + + let activity = load_dashboard_activity_snapshot(&conn, 1); + + assert_eq!(activity.board_count, 1); + assert_eq!(activity.active_threads, Some(1)); + assert_eq!(activity.total_threads, Some(2)); + assert_eq!(activity.total_posts, Some(2)); + assert_eq!(activity.posts_24h, Some(1)); + assert_eq!(activity.posts_7d, Some(1)); + assert_eq!(activity.upload_posts, Some(2)); + assert_eq!(activity.total_images, Some(1)); + assert_eq!(activity.total_videos, Some(1)); + assert_eq!(activity.total_audio, Some(1)); + assert_eq!(activity.active_bytes, Some(18)); + assert_eq!(activity.recent_reports_7d, Some(1)); + } + + #[test] + fn dashboard_backup_status_classifies_warning_and_ok_states() { + let missing = BackupSummary { + warning: Some( + "No saved full backup found. Create and download a verified full backup before relying on this node." + .to_owned(), + ), + status_line: "Latest full backup: none saved.".to_owned(), + }; + let stale = BackupSummary { + warning: Some( + "Latest verified full backup 'backup.zip' is older than 72 hours (96h ago)." + .to_owned(), + ), + status_line: "Latest full backup: backup.zip (96h ago) - verified.".to_owned(), + }; + let ok = BackupSummary { + warning: None, + status_line: "Latest full backup: backup.zip (1h ago) - verified.".to_owned(), + }; + + assert_eq!( + dashboard_backup_status(&missing).2, + crate::templates::AdminDashboardState::ActionNeeded + ); + assert_eq!( + dashboard_backup_status(&stale).2, + crate::templates::AdminDashboardState::Warning + ); + assert_eq!( + dashboard_backup_status(&ok).2, + crate::templates::AdminDashboardState::Ok + ); + } + fn same_origin_headers(host: &str) -> HeaderMap { let mut headers = HeaderMap::new(); headers.insert( @@ -1737,6 +2549,77 @@ mod tests { assert!(require_same_origin_request(&headers, None).is_ok()); } + #[test] + fn same_origin_request_accepts_valid_onion_http_origin() { + let mut headers = same_origin_headers(TEST_ONION_HOST); + headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN)); + + assert!(require_same_origin_request(&headers, None).is_ok()); + } + + #[test] + fn same_origin_request_accepts_valid_onion_http_referer() { + let mut headers = same_origin_headers(TEST_ONION_HOST); + headers.insert( + header::REFERER, + HeaderValue::from_static(concat!( + "http://aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaam2dqd.onion", + "/admin/panel" + )), + ); + + assert!(require_same_origin_request(&headers, None).is_ok()); + } + + #[test] + fn same_origin_request_rejects_spoofed_onion_origin() { + let mut headers = same_origin_headers(TEST_ONION_HOST); + headers.insert( + header::ORIGIN, + HeaderValue::from_static( + "http://bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbm2dqd.onion", + ), + ); + + assert!(require_same_origin_request(&headers, None).is_err()); + } + + #[test] + fn same_origin_request_keeps_onion_http_when_tls_uses_default_https_port() { + let mut headers = same_origin_headers(TEST_ONION_HOST); + headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN)); + + assert_eq!( + request_scheme_for_same_origin_with_config( + &headers, + None, + TEST_ONION_HOST, + false, + true, + 443, + ), + "http" + ); + assert!(require_same_origin_request(&headers, None).is_ok()); + } + + #[test] + fn same_origin_request_preserves_default_https_port_for_clearnet_hosts() { + let headers = same_origin_headers("example.test"); + + assert_eq!( + request_scheme_for_same_origin_with_config( + &headers, + None, + "example.test", + false, + true, + 443, + ), + "https" + ); + } + #[test] fn same_origin_request_accepts_missing_origin_and_referer_with_same_origin_fetch_metadata() { let mut headers = same_origin_headers("demo.serveo.net"); @@ -1928,14 +2811,20 @@ mod tests { header::HOST, HeaderValue::from_str(&host).expect("host header"), ); - assert!(host_header_uses_https_port(&headers)); + assert!(host_header_uses_https_port_with_config( + &headers, + crate::config::CONFIG.tls.port + )); } #[test] fn http_host_port_does_not_mark_request_secure() { let mut headers = HeaderMap::new(); headers.insert(header::HOST, HeaderValue::from_static("example.test:8080")); - assert!(!host_header_uses_https_port(&headers)); + assert!(!host_header_uses_https_port_with_config( + &headers, + crate::config::CONFIG.tls.port + )); } #[test] @@ -1992,6 +2881,33 @@ mod tests { )); } + #[test] + fn secure_cookie_decision_keeps_onion_plain_http_cookie_insecure() { + let mut headers = same_origin_headers(TEST_ONION_HOST); + headers.insert(header::ORIGIN, HeaderValue::from_static(TEST_ONION_ORIGIN)); + let context = + SecureCookieContext::new(Some("127.0.0.1:41000".parse().expect("peer")), false); + + assert!(!should_set_secure_cookie_with_config( + &headers, context, true, false, + )); + } + + #[test] + fn secure_cookie_decision_marks_direct_onion_https_cookie_secure() { + let mut headers = same_origin_headers(TEST_ONION_HOST); + headers.insert( + header::ORIGIN, + HeaderValue::from_static(TEST_ONION_HTTPS_ORIGIN), + ); + let context = + SecureCookieContext::new(Some("127.0.0.1:41000".parse().expect("peer")), true); + + assert!(should_set_secure_cookie_with_config( + &headers, context, true, false, + )); + } + #[test] fn secure_cookie_decision_requires_trusted_proxy_for_forwarded_proto() { let mut headers = HeaderMap::new(); @@ -2077,6 +2993,14 @@ mod tests { .expect("create session"); } + fn admin_signed_csrf() -> String { + crate::utils::crypto::make_scoped_csrf_form_token( + "csrf123", + &crate::config::CONFIG.cookie_secret, + "session123", + ) + } + #[tokio::test] async fn live_log_requires_admin_auth() { let state = crate::test_support::app_state(); @@ -2209,6 +3133,65 @@ mod tests { assert!(error.chars().count() <= 183); } + #[tokio::test] + async fn dismiss_failed_site_health_jobs_resets_counter_without_deleting_history() { + let state = crate::test_support::app_state(); + install_admin_session(&state); + { + let conn = state.db.get().expect("db connection"); + conn.execute( + "INSERT INTO background_jobs + (job_type, payload, status, attempts, last_error, updated_at) + VALUES + ('video_transcode', '{}', 'failed', 3, 'ffmpeg failed', unixepoch())", + [], + ) + .expect("insert failed job"); + assert_eq!( + crate::db::background_job_summary(&conn) + .expect("summary before dismiss") + .failed, + 1 + ); + } + let mut headers = same_origin_headers("localhost"); + headers.insert(header::ORIGIN, HeaderValue::from_static("http://localhost")); + let response = dismiss_failed_site_health_jobs( + State(state.clone()), + CookieJar::new() + .add(Cookie::new("csrf_token", "csrf123")) + .add(Cookie::new(SESSION_COOKIE, "session123")), + headers, + ConnectInfo("127.0.0.1:3000".parse().expect("peer address")), + Form(DismissFailedJobsForm { + csrf: Some(admin_signed_csrf()), + }), + ) + .await + .expect("dismiss response"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + assert_eq!( + response.headers().get(header::LOCATION), + Some(&HeaderValue::from_static( + "/admin/panel?flash=Failed%20job%20counter%20dismissed.&open=site-health#site-health" + )) + ); + let conn = state.db.get().expect("db connection"); + assert_eq!( + crate::db::background_job_summary(&conn) + .expect("summary after dismiss") + .failed, + 0 + ); + assert_eq!( + crate::db::recent_background_jobs(&conn, "failed", 10) + .expect("recent failed history") + .len(), + 1 + ); + } + #[tokio::test] async fn live_log_returns_no_store_headers_and_json_body() { let state = crate::test_support::app_state(); diff --git a/src/handlers/admin/settings/board.rs b/src/handlers/admin/settings/board.rs index ff9b7751..62b3990c 100644 --- a/src/handlers/admin/settings/board.rs +++ b/src/handlers/admin/settings/board.rs @@ -19,6 +19,7 @@ pub struct BoardSettingsForm { max_image_size_mb: Option, max_video_size_mb: Option, max_audio_size_mb: Option, + max_pdf_size_mb: Option, allow_pdf: Option, allow_any_files: Option, allow_tripcodes: Option, @@ -194,6 +195,10 @@ pub async fn update_board_settings( form.max_audio_size_mb.as_deref(), current_board.max_audio_size, )?; + let max_pdf_size = parse_board_upload_limit_bytes( + form.max_pdf_size_mb.as_deref(), + current_board.max_pdf_size, + )?; db::update_board_settings( &mut conn, board_id, @@ -209,6 +214,7 @@ pub async fn update_board_settings( max_image_size, max_video_size, max_audio_size, + max_pdf_size, form.allow_pdf.as_deref() == Some("1"), CONFIG.enable_any_file_uploads_feature && form.allow_any_files.as_deref() == Some("1"), diff --git a/src/handlers/board.rs b/src/handlers/board.rs index f612827a..4c65fc40 100644 --- a/src/handlers/board.rs +++ b/src/handlers/board.rs @@ -261,6 +261,17 @@ pub(crate) fn xhr_post_error_response(error: AppError) -> Result { } } +pub(crate) fn handled_post_error_status( + error: AppError, +) -> std::result::Result<(StatusCode, String), AppError> { + match error { + AppError::BadRequest(message) => Ok((StatusCode::UNPROCESSABLE_ENTITY, message)), + AppError::UploadTooLarge(message) => Ok((StatusCode::PAYLOAD_TOO_LARGE, message)), + AppError::InvalidMediaType(message) => Ok((StatusCode::UNSUPPORTED_MEDIA_TYPE, message)), + other => Err(other), + } +} + #[derive(Deserialize)] pub struct BannedPageQuery { pub reason: Option, diff --git a/src/handlers/board/access_preferences.rs b/src/handlers/board/access_preferences.rs index 9738f37c..80871d19 100644 --- a/src/handlers/board/access_preferences.rs +++ b/src/handlers/board/access_preferences.rs @@ -514,14 +514,28 @@ pub fn ensure_csrf_for_request( ensure_csrf_with_secure(jar, should_set_public_secure_cookie(headers, context)) } +#[derive(serde::Deserialize)] +pub struct ThemePreferenceQuery { + pub return_to: Option, + #[serde(rename = "_csrf")] + pub csrf: Option, +} + pub async fn set_theme( Path(theme): Path, - Query(params): Query>, + Query(params): Query, jar: CookieJar, headers: HeaderMap, ) -> Result { let theme = crate::templates::normalize_theme_slug(&theme) .ok_or_else(|| AppError::BadRequest("Unknown theme.".into()))?; + if params + .csrf + .as_deref() + .is_some_and(|value| !value.is_empty()) + { + check_csrf_jar(&jar, params.csrf.as_deref())?; + } let mut cookie = Cookie::new(USER_THEME_COOKIE, theme.to_string()); cookie.set_http_only(false); @@ -540,8 +554,9 @@ pub async fn set_theme( } let redirect_to = params - .get("return_to") - .map(|value| safe_return_to(Some(value.as_str()), "/")) + .return_to + .as_deref() + .map(|value| safe_return_to(Some(value), "/")) .or_else(|| safe_referer_return_to(&headers)) .unwrap_or_else(|| "/".to_owned()); Ok((jar, Redirect::to(&redirect_to)).into_response()) diff --git a/src/handlers/board/create_thread.rs b/src/handlers/board/create_thread.rs index a492fc1a..e7d7bfba 100644 --- a/src/handlers/board/create_thread.rs +++ b/src/handlers/board/create_thread.rs @@ -51,6 +51,7 @@ pub async fn create_thread( access_context.board.max_image_size_bytes(), access_context.board.max_video_size_bytes(), access_context.board.max_audio_size_bytes(), + access_context.board.max_pdf_size_bytes(), ), ) .await @@ -120,13 +121,23 @@ pub async fn create_thread( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - // BadRequest → re-render the board page with an inline error banner so the - // user sees the message in context without being sent to a separate error page. + // Expected post validation errors re-render the board page with an inline + // error banner so the user sees the message in context without being sent + // to a separate error page. let submit_result = match result { Ok(submit_result) => submit_result, - Err(AppError::BadRequest(msg)) => { + Err(error) => { + let (status, msg) = match handled_post_error_status(error) { + Ok(handled) => handled, + Err(error) => { + if xhr_request { + return xhr_post_error_response(error); + } + return Err(error); + } + }; if xhr_request { - return xhr_handled_error_response(StatusCode::UNPROCESSABLE_ENTITY, &msg); + return xhr_error_response(status, &msg); } let board_short_render = board_short_err.clone(); let pool = state.db.clone(); @@ -181,15 +192,9 @@ pub async fn create_thread( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; let mut resp = Html(html).into_response(); - *resp.status_mut() = axum::http::StatusCode::UNPROCESSABLE_ENTITY; + *resp.status_mut() = status; return Ok(resp); } - Err(error) => { - if xhr_request { - return xhr_post_error_response(error); - } - return Err(error); - } }; let jar = remember_owned_post_until_with_secure( diff --git a/src/handlers/board/media.rs b/src/handlers/board/media.rs index c39a89c2..fdc4f791 100644 --- a/src/handlers/board/media.rs +++ b/src/handlers/board/media.rs @@ -24,8 +24,10 @@ fn media_content_type(path: &std::path::Path) -> Option<&'static str> { // absence here documents the security decision. Some("webm") => Some("video/webm"), Some("mp4") => Some("video/mp4"), + Some("mkv") => Some("video/x-matroska"), Some("mp3") => Some("audio/mpeg"), - Some("ogg") => Some("audio/ogg"), + Some("ogg" | "oga") => Some("audio/ogg"), + Some("opus") => Some("audio/opus"), Some("flac") => Some("audio/flac"), Some("wav") => Some("audio/wav"), Some("m4a") => Some("audio/mp4"), diff --git a/src/handlers/board/tests.rs b/src/handlers/board/tests.rs index 9117be54..dabb6830 100644 --- a/src/handlers/board/tests.rs +++ b/src/handlers/board/tests.rs @@ -932,7 +932,7 @@ async fn create_thread_xhr_validation_failure_returns_json_error() { .await .expect("response"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( response .headers() @@ -958,6 +958,97 @@ async fn create_thread_xhr_validation_failure_returns_json_error() { assert!(body.contains("\"error\"")); } +#[tokio::test] +async fn create_thread_rejects_mime_mismatch_with_415_inline_error() { + let state = crate::test_support::app_state(); + { + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, "test", "Test", "", false).expect("create board"); + } + + let router = Router::new() + .route("/{board}", post(super::create_thread)) + .with_state(state); + let (boundary, body) = crate::test_support::multipart_body( + &[("_csrf", "csrf123"), ("body", "bad media")], + Some(("file", "fake.png", b"plain text", "image/png")), + ); + + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/test") + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + let body = String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body") + .to_vec(), + ) + .expect("utf8 body"); + assert!(body.contains("post-error-banner")); + assert!(body.contains("File type not allowed")); +} + +#[tokio::test] +async fn create_thread_rejects_truncated_png_with_422_inline_error() { + let state = crate::test_support::app_state(); + { + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, "test", "Test", "", false).expect("create board"); + } + + let router = Router::new() + .route("/{board}", post(super::create_thread)) + .with_state(state); + let truncated_png = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR"; + let (boundary, body) = crate::test_support::multipart_body( + &[("_csrf", "csrf123"), ("body", "bad png")], + Some(("file", "truncated.png", truncated_png, "image/png")), + ); + + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/test") + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); + let body = String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body") + .to_vec(), + ) + .expect("utf8 body"); + assert!(body.contains("post-error-banner")); + assert!(body.contains("image header is malformed")); +} + #[tokio::test] async fn homepage_and_thread_badges_default_to_enabled() { let state = crate::test_support::app_state(); @@ -2513,7 +2604,7 @@ async fn create_thread_xhr_captcha_failure_returns_inline_json_error() { .await .expect("response"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( response .headers() @@ -2645,6 +2736,7 @@ async fn create_thread_rejects_uploads_on_upload_disabled_board() { i64::try_from(crate::config::CONFIG.max_image_size).expect("image size fits in i64"), i64::try_from(crate::config::CONFIG.max_video_size).expect("video size fits in i64"), i64::try_from(crate::config::CONFIG.max_audio_size).expect("audio size fits in i64"), + i64::try_from(crate::config::CONFIG.max_image_size).expect("pdf size fits in i64"), false, false, true, @@ -2907,7 +2999,7 @@ async fn changing_board_password_invalidates_existing_unlock_cookie() { } #[tokio::test] -async fn theme_redirect_rejects_external_referer_fallback() { +async fn theme_redirect_ignores_external_referer_fallback() { let router = Router::new() .route("/theme/{theme}", get(crate::handlers::board::set_theme)) .with_state(crate::test_support::app_state()); @@ -2917,6 +3009,7 @@ async fn theme_redirect_rejects_external_referer_fallback() { Request::builder() .method("GET") .uri("/theme/forest") + .header(header::COOKIE, "csrf_token=csrf123") .header(header::REFERER, "https://evil.example/secret/catalog") .body(Body::empty()) .expect("request"), @@ -2934,6 +3027,83 @@ async fn theme_redirect_rejects_external_referer_fallback() { ); } +#[tokio::test] +async fn theme_redirect_persists_no_js_theme_without_csrf() { + let router = Router::new() + .route("/theme/{theme}", get(crate::handlers::board::set_theme)) + .with_state(crate::test_support::app_state()); + + let accepted = router + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/theme/blue-sky?return_to=%2Fsecret%2Fcatalog") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("accepted response"); + + assert_eq!(accepted.status(), StatusCode::SEE_OTHER); + assert_eq!( + accepted + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("/secret/catalog") + ); + assert!(accepted + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| value.starts_with("rustchan_theme=blue-sky;"))); + + let rejected = router + .clone() + .oneshot( + Request::builder() + .method("GET") + .uri("/theme/forest?return_to=%2Fsecret%2Fcatalog&_csrf=wrong") + .header(header::COOKIE, "csrf_token=csrf123") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("rejected response"); + + assert_eq!(rejected.status(), StatusCode::FORBIDDEN); + assert!(rejected.headers().get(header::SET_COOKIE).is_none()); + + let accepted = router + .oneshot( + Request::builder() + .method("GET") + .uri("/theme/forest?return_to=%2Fsecret%2Fcatalog&_csrf=csrf123") + .header(header::COOKIE, "csrf_token=csrf123") + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("accepted response"); + + assert_eq!(accepted.status(), StatusCode::SEE_OTHER); + assert_eq!( + accepted + .headers() + .get(header::LOCATION) + .and_then(|value| value.to_str().ok()), + Some("/secret/catalog") + ); + assert!(accepted + .headers() + .get_all(header::SET_COOKIE) + .iter() + .filter_map(|value| value.to_str().ok()) + .any(|value| value.starts_with("rustchan_theme=forest;"))); +} + #[test] fn user_preferences_from_jar_defaults_and_ignores_invalid_values() { let mut headers = HeaderMap::new(); diff --git a/src/handlers/mod.rs b/src/handlers/mod.rs index a30eeab9..be64bf79 100644 --- a/src/handlers/mod.rs +++ b/src/handlers/mod.rs @@ -7,6 +7,7 @@ pub mod captcha; pub mod favicon; pub mod posting; pub mod render; +pub mod setup; pub mod thread; // ─── Shared multipart form parsing ─────────────────────────────────────────── @@ -195,8 +196,8 @@ async fn stream_field_to_temp_file( "multipart upload field exceeded board limit" ); return Err(AppError::UploadTooLarge(format!( - "File too large. Maximum upload size is {} MiB.", - max_bytes / 1024 / 1024 + "File too large. Maximum upload size is {}.", + format_upload_limit(max_bytes) ))); } if sniff_bytes.len() < MIME_SNIFF_BYTES { @@ -229,6 +230,10 @@ async fn stream_field_to_temp_file( }) } +fn format_upload_limit(max_bytes: usize) -> String { + crate::utils::files::format_file_size(i64::try_from(max_bytes).unwrap_or(i64::MAX)) +} + async fn read_upload_field( field: axum::extract::multipart::Field<'_>, max_bytes: usize, @@ -297,11 +302,13 @@ pub async fn parse_post_multipart( max_image_size: usize, max_video_size: usize, max_audio_size: usize, + max_pdf_size: usize, ) -> Result { tracing::info!( max_image_bytes = max_image_size, max_video_bytes = max_video_size, max_audio_bytes = max_audio_size, + max_pdf_bytes = max_pdf_size, "accepted multipart upload limits for post request" ); @@ -393,7 +400,10 @@ pub async fn parse_post_multipart( } file = read_upload_field( field, - max_image_size.max(max_video_size).max(max_audio_size), + max_image_size + .max(max_video_size) + .max(max_audio_size) + .max(max_pdf_size), "upload", "file", &mut budget, @@ -523,6 +533,7 @@ pub fn process_primary_upload( max_image_size: usize, max_video_size: usize, max_audio_size: usize, + max_pdf_size: usize, ffmpeg_available: bool, ffprobe_available: bool, ffmpeg_webp_available: bool, @@ -538,7 +549,7 @@ pub fn process_primary_upload( ffprobe_available, allow_any_files, ) - .map_err(|error| AppError::BadRequest(error.to_string()))?; + .map_err(|error| classify_upload_error(&error))?; let detected_media = crate::models::MediaType::from_mime(&detected_mime); match detected_media { @@ -586,6 +597,7 @@ pub fn process_primary_upload( max_image_size, max_video_size, max_audio_size, + max_pdf_size, ffmpeg_available, ffprobe_available, ffmpeg_webp_available, @@ -661,6 +673,7 @@ pub fn process_primary_upload( max_image_size, max_video_size, max_audio_size, + max_pdf_size, ffmpeg_available, ffprobe_available, ffmpeg_webp_available, @@ -692,7 +705,7 @@ fn temp_upload_mime( ffprobe_available, allow_any_files, ) - .map_err(|error| AppError::BadRequest(error.to_string())) + .map_err(|error| classify_upload_error(&error)) } /// Process the secondary audio file for an image+audio combo upload. @@ -760,6 +773,7 @@ pub fn process_audio_first_uploads( max_image_size: usize, max_video_size: usize, max_audio_size: usize, + max_pdf_size: usize, ffmpeg_available: bool, ffprobe_available: bool, ffmpeg_webp_available: bool, @@ -782,6 +796,7 @@ pub fn process_audio_first_uploads( max_image_size, max_video_size, max_audio_size, + max_pdf_size, ffmpeg_available, ffprobe_available, ffmpeg_webp_available, @@ -1002,7 +1017,8 @@ trailer << /Root 1 0 R >> async fn parse_scaled_audio_limit( multipart: axum::extract::Multipart, ) -> crate::error::Result<&'static str> { - let form = parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000).await?; + let form = + parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000, 1_024).await?; let (upload, _) = form.audio_file.expect("audio upload"); assert_eq!(upload.size_bytes, 4_500); Ok("ok") @@ -1011,7 +1027,7 @@ trailer << /Root 1 0 R >> async fn parse_scaled_audio_oversize( multipart: axum::extract::Multipart, ) -> crate::error::Result<&'static str> { - parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000).await?; + parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 5_000, 1_024).await?; Ok("ok") } @@ -1088,7 +1104,17 @@ trailer << /Root 1 0 R >> async fn parse_default_limits( multipart: axum::extract::Multipart, ) -> crate::error::Result<&'static str> { - parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 1_024).await?; + parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 1_024, 1_024).await?; + Ok("ok") + } + + async fn parse_pdf_limit( + multipart: axum::extract::Multipart, + ) -> crate::error::Result<&'static str> { + let form = + parse_post_multipart(multipart, Some("csrf123"), 1_024, 1_024, 1_024, 2_048).await?; + let (upload, _) = form.file.expect("file upload"); + assert_eq!(upload.size_bytes, 2_048); Ok("ok") } @@ -1182,6 +1208,56 @@ trailer << /Root 1 0 R >> assert_eq!(response.status(), StatusCode::BAD_REQUEST); } + #[tokio::test] + async fn multipart_parser_counts_file_payload_not_multipart_overhead_for_exact_limit() { + let router = Router::new().route("/parse", post(parse_pdf_limit)); + let pdf = vec![b'p'; 2_048]; + let (boundary, body) = multipart_body_with_files( + &[("_csrf", "csrf123"), ("body", "pdf")], + &[("file", "exact.pdf", &pdf, "application/pdf")], + ); + + let response = router + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri("/parse") + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + + let over_pdf = vec![b'p'; 2_049]; + let (boundary, body) = multipart_body_with_files( + &[("_csrf", "csrf123"), ("body", "pdf")], + &[("file", "over.pdf", &over_pdf, "application/pdf")], + ); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri("/parse") + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); + } + #[tokio::test] async fn multipart_parser_ignores_empty_unselected_file_control() { let router = Router::new().route("/parse", post(parse_default_limits)); @@ -1244,6 +1320,7 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, 1024 * 1024, + 1024 * 1024, false, false, false, @@ -1296,6 +1373,7 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, 1024 * 1024, + 1024 * 1024, false, false, false, @@ -1351,6 +1429,7 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, 1024 * 1024, + 1024 * 1024, false, false, false, @@ -1385,6 +1464,7 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, 1024 * 1024, + 1024 * 1024, false, false, false, @@ -1397,6 +1477,42 @@ trailer << /Root 1 0 R >> assert!(!save_root.path().join(&board.short_name).exists()); } + #[test] + fn primary_upload_rejects_pdf_over_pdf_size_limit() { + let conn = rusqlite::Connection::open_in_memory().expect("in-memory sqlite"); + create_file_hash_table(&conn); + + let board = crate::models::Board { + allow_pdf: true, + max_pdf_size: 8, + max_video_size: 1024 * 1024, + max_audio_size: 1024 * 1024, + ..crate::test_fixtures::sample_board() + }; + let uploads_dir = tempfile::tempdir().expect("uploads dir"); + let save_root = tempfile::tempdir().expect("save root"); + let result = super::process_primary_upload( + Some(temp_upload("doc.pdf", valid_pdf())), + &board, + &conn, + uploads_dir.path().to_str().expect("uploads dir path"), + save_root.path().to_str().expect("save root path"), + 64, + 1024 * 1024, + 1024 * 1024, + 1024 * 1024, + board.max_pdf_size_bytes(), + false, + false, + false, + ); + + match result { + Ok(_) => panic!("oversized PDF should be rejected"), + Err(error) => assert!(error.to_string().contains("Maximum PDF upload size is 8 B")), + } + } + #[test] fn primary_upload_rejects_renamed_non_pdf() { let conn = rusqlite::Connection::open_in_memory().expect("in-memory sqlite"); @@ -1418,6 +1534,7 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, 1024 * 1024, + 1024 * 1024, false, false, false, @@ -1454,6 +1571,7 @@ trailer << /Root 1 0 R >> 1024 * 1024, 1024 * 1024, 1024 * 1024, + 1024 * 1024, false, false, false, diff --git a/src/handlers/posting.rs b/src/handlers/posting.rs index bcf00e10..e420baf8 100644 --- a/src/handlers/posting.rs +++ b/src/handlers/posting.rs @@ -67,6 +67,7 @@ pub struct UploadConfig<'a> { pub max_image_size: usize, pub max_video_size: usize, pub max_audio_size: usize, + pub max_pdf_size: usize, pub ffmpeg_available: bool, pub ffprobe_available: bool, pub ffmpeg_webp_available: bool, @@ -315,6 +316,7 @@ pub fn process_uploads( config.max_image_size, config.max_video_size, config.max_audio_size, + config.max_pdf_size, config.ffmpeg_available, config.ffprobe_available, config.ffmpeg_webp_available, @@ -424,6 +426,7 @@ pub fn submit_post( let effective_max_image_size = board.max_image_size_bytes(); let effective_max_video_size = board.max_video_size_bytes(); let effective_max_audio_size = board.max_audio_size_bytes(); + let effective_max_pdf_size = board.max_pdf_size_bytes(); let reply_context = match &mode { SubmitPostMode::Reply { thread_id, sage } => { @@ -517,6 +520,7 @@ pub fn submit_post( max_image_size: effective_max_image_size, max_video_size: effective_max_video_size, max_audio_size: effective_max_audio_size, + max_pdf_size: effective_max_pdf_size, ffmpeg_available, ffprobe_available, ffmpeg_webp_available, @@ -556,18 +560,23 @@ pub fn submit_post( None } else { if q.is_empty() { + uploads.rollback_new_files(conn, &upload_dir)?; return Err(AppError::BadRequest( "Polls need a question and at least two options.".into(), )); } if valid_opts.len() < 2 { + uploads.rollback_new_files(conn, &upload_dir)?; return Err(AppError::BadRequest( "Polls need a question and at least two options.".into(), )); } - let secs = poll_duration_secs.ok_or_else(|| { - AppError::BadRequest("A duration is required when creating a poll.".into()) - })?; + let Some(secs) = poll_duration_secs else { + uploads.rollback_new_files(conn, &upload_dir)?; + return Err(AppError::BadRequest( + "A duration is required when creating a poll.".into(), + )); + }; let secs = secs.clamp(60, 30 * 24 * 3600); let expires_at = chrono::Utc::now().timestamp().saturating_add(secs); Some(db::threads::PollInsert { @@ -864,6 +873,26 @@ mod tests { bytes } + fn flac_header_bytes() -> Vec { + b"fLaC\x00\x00\x00\x22tiny test flac bytes".to_vec() + } + + fn malformed_aac_bytes() -> Vec { + let mut bytes = Vec::with_capacity(2_048); + bytes.extend_from_slice(&[0xFF, 0xF1, 0x50, 0x80]); + bytes.resize(2_048, 0); + let mut state = 4_u32; + for byte in bytes.iter_mut().skip(4) { + state = state.wrapping_mul(1_664_525).wrapping_add(1_013_904_223); + *byte = u8::try_from(state & 0xFF).expect("masked byte fits in u8"); + } + bytes + } + + fn webm_header_bytes() -> Vec { + b"\x1a\x45\xdf\xa3\x00\x00\x00\x00\x00\x00\x42\x82\x84webm\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00".to_vec() + } + fn pending_upload_stage_count(upload_dir: &std::path::Path) -> usize { let pending = upload_dir.join(".pending"); if !pending.exists() { @@ -874,6 +903,17 @@ mod tests { .count() } + fn assert_no_partial_post_rows(conn: &rusqlite::Connection) { + let thread_count: i64 = conn + .query_row("SELECT COUNT(*) FROM threads", [], |row| row.get(0)) + .expect("thread count"); + let post_count: i64 = conn + .query_row("SELECT COUNT(*) FROM posts", [], |row| row.get(0)) + .expect("post count"); + assert_eq!(thread_count, 0); + assert_eq!(post_count, 0); + } + #[test] fn submit_post_rejects_banned_user_before_creating_thread() { let state = crate::test_support::app_state(); @@ -1146,6 +1186,7 @@ mod tests { max_image_size: 1024 * 1024, max_video_size: 1024 * 1024, max_audio_size: 1024 * 1024, + max_pdf_size: 1024 * 1024, ffmpeg_available: false, ffprobe_available: false, ffmpeg_webp_available: false, @@ -1185,10 +1226,238 @@ mod tests { match error { AppError::UploadTooLarge(message) => { - assert!(message.contains("Maximum image size is 0 MiB.")); + assert!(message.contains("Maximum image upload size is 64 B.")); + } + other => panic!("expected UploadTooLarge, got {other:?}"), + } + } + + #[test] + fn submit_post_rejects_disabled_audio_without_stale_uploads_or_rows() { + let state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir().expect("upload dir"); + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board"); + let mut command = thread_command( + TEST_BOARD, + "audio-disabled", + "thread body", + upload_dir.path().to_str().expect("upload dir"), + ); + command.file_data = Some(temp_upload("tiny.flac", &flac_header_bytes())); + + let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + Ok(result) => panic!( + "audio-disabled board should reject, got {}", + result.redirect_url + ), + Err(error) => error, + }; + + match error { + AppError::BadRequest(message) => { + assert!(message.contains("Audio uploads are disabled")); + } + other => panic!("expected BadRequest, got {other:?}"), + } + assert_eq!(pending_upload_stage_count(upload_dir.path()), 0); + assert!(!upload_dir.path().join(TEST_BOARD).exists()); + assert_no_partial_post_rows(&conn); + } + + #[test] + fn submit_post_rejects_malformed_aac_without_jobs_or_rows() { + let state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir().expect("upload dir"); + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board"); + conn.execute( + "UPDATE boards SET allow_audio = 1 WHERE short_name = ?1", + rusqlite::params![TEST_BOARD], + ) + .expect("enable audio"); + let mut command = thread_command( + TEST_BOARD, + "malformed-aac", + "thread body", + upload_dir.path().to_str().expect("upload dir"), + ); + command.file_data = Some(temp_upload("broken.aac", &malformed_aac_bytes())); + command.ffmpeg_available = true; + + let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + Ok(result) => panic!("malformed AAC should reject, got {}", result.redirect_url), + Err(error) => error, + }; + + match error { + AppError::BadRequest(message) => { + assert!(message.contains("ADTS stream is malformed")); + } + other => panic!("expected BadRequest, got {other:?}"), + } + assert_eq!(pending_upload_stage_count(upload_dir.path()), 0); + assert!(!upload_dir.path().join(TEST_BOARD).exists()); + assert_no_partial_post_rows(&conn); + let job_count: i64 = conn + .query_row("SELECT COUNT(*) FROM background_jobs", [], |row| row.get(0)) + .expect("job count"); + assert_eq!(job_count, 0); + } + + #[test] + fn submit_post_rejects_disabled_video_without_stale_uploads_or_rows() { + let state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir().expect("upload dir"); + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board"); + conn.execute( + "UPDATE boards SET allow_video = 0 WHERE short_name = ?1", + rusqlite::params![TEST_BOARD], + ) + .expect("disable video"); + let mut command = thread_command( + TEST_BOARD, + "video-disabled", + "thread body", + upload_dir.path().to_str().expect("upload dir"), + ); + command.file_data = Some(temp_upload("tiny.webm", &webm_header_bytes())); + + let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + Ok(result) => panic!( + "video-disabled board should reject, got {}", + result.redirect_url + ), + Err(error) => error, + }; + + match error { + AppError::BadRequest(message) => { + assert!(message.contains("Video uploads are disabled")); + } + other => panic!("expected BadRequest, got {other:?}"), + } + assert_eq!(pending_upload_stage_count(upload_dir.path()), 0); + assert!(!upload_dir.path().join(TEST_BOARD).exists()); + assert_no_partial_post_rows(&conn); + } + + #[test] + fn submit_post_rejects_overlimit_audio_without_stale_uploads_or_rows() { + let state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir().expect("upload dir"); + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board"); + conn.execute( + "UPDATE boards SET allow_audio = 1, max_audio_size = 8 WHERE short_name = ?1", + rusqlite::params![TEST_BOARD], + ) + .expect("shrink audio limit"); + let mut command = thread_command( + TEST_BOARD, + "audio-overlimit", + "thread body", + upload_dir.path().to_str().expect("upload dir"), + ); + command.file_data = Some(temp_upload("tiny.flac", &flac_header_bytes())); + + let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + Ok(result) => panic!( + "over-limit audio should reject, got {}", + result.redirect_url + ), + Err(error) => error, + }; + + match error { + AppError::UploadTooLarge(message) => { + assert!(message.contains("Maximum audio upload size is 8 B.")); } other => panic!("expected UploadTooLarge, got {other:?}"), } + assert_eq!(pending_upload_stage_count(upload_dir.path()), 0); + assert!(!upload_dir.path().join(TEST_BOARD).exists()); + assert_no_partial_post_rows(&conn); + } + + #[test] + fn submit_post_rejects_overlimit_video_without_stale_uploads_or_rows() { + let state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir().expect("upload dir"); + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board"); + conn.execute( + "UPDATE boards SET max_video_size = 8 WHERE short_name = ?1", + rusqlite::params![TEST_BOARD], + ) + .expect("shrink video limit"); + let mut command = thread_command( + TEST_BOARD, + "video-overlimit", + "thread body", + upload_dir.path().to_str().expect("upload dir"), + ); + command.file_data = Some(temp_upload("tiny.webm", &webm_header_bytes())); + + let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + Ok(result) => panic!( + "over-limit video should reject, got {}", + result.redirect_url + ), + Err(error) => error, + }; + + match error { + AppError::UploadTooLarge(message) => { + assert!(message.contains("Maximum video upload size is 8 B.")); + } + other => panic!("expected UploadTooLarge, got {other:?}"), + } + assert_eq!(pending_upload_stage_count(upload_dir.path()), 0); + assert!(!upload_dir.path().join(TEST_BOARD).exists()); + assert_no_partial_post_rows(&conn); + } + + #[test] + fn submit_post_cleans_staged_upload_when_poll_validation_fails() { + let state = crate::test_support::app_state(); + let upload_dir = tempfile::tempdir().expect("upload dir"); + let conn = state.db.get().expect("db connection"); + crate::db::create_board(&conn, TEST_BOARD, "Test", "", false).expect("create board"); + + let mut command = thread_command_with_poll( + TEST_BOARD, + "poll-upload-token", + "thread body", + upload_dir.path().to_str().expect("upload dir"), + "pick one", + vec!["only option"], + Some(3600), + ); + command.file_data = Some(temp_upload("cover.png", &one_pixel_png())); + + let error = match submit_post(&conn, state.job_queue.as_ref(), command) { + Ok(result) => panic!( + "poll validation should reject submission, got {}", + result.redirect_url + ), + Err(error) => error, + }; + + match error { + AppError::BadRequest(message) => { + assert_eq!(message, "Polls need a question and at least two options."); + } + other => panic!("expected BadRequest, got {other:?}"), + } + + assert_eq!(pending_upload_stage_count(upload_dir.path()), 0); + assert!(!upload_dir.path().join(TEST_BOARD).exists()); + let post_count: i64 = conn + .query_row("SELECT COUNT(*) FROM posts", [], |row| row.get(0)) + .expect("post count"); + assert_eq!(post_count, 0); } #[test] @@ -1254,6 +1523,7 @@ mod tests { max_image_size: 1024 * 1024, max_video_size: 1024 * 1024, max_audio_size: 1024 * 1024, + max_pdf_size: 1024 * 1024, ffmpeg_available: false, ffprobe_available: false, ffmpeg_webp_available: false, diff --git a/src/handlers/setup.rs b/src/handlers/setup.rs new file mode 100644 index 00000000..1f46caaf --- /dev/null +++ b/src/handlers/setup.rs @@ -0,0 +1,1757 @@ +#![allow(clippy::too_many_lines)] + +use crate::{ + config::CONFIG, + db, + error::{AppError, Result}, + middleware::AppState, + templates, + utils::{crypto, sanitize::escape_html}, +}; +use axum::{ + extract::{Form, Query, State}, + http::{header, HeaderMap, StatusCode}, + response::{Html, IntoResponse as _, Redirect, Response}, +}; +use axum_extra::extract::cookie::{Cookie, CookieJar, SameSite}; +use serde::{Deserialize, Serialize}; +use std::fmt::Write as _; + +const SETUP_CSRF_SCOPE: &str = "first-run-setup"; +const SETUP_PENDING_ADMIN_HASH_COOKIE: &str = "setup_pending_admin_hash"; +const MIB: u64 = 1024 * 1024; +const MIB_I64: i64 = 1024 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SetupPreset { + Public, + Private, + Local, +} + +impl SetupPreset { + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Public => "public", + Self::Private => "private", + Self::Local => "local", + } + } + + #[must_use] + pub const fn label(self) -> &'static str { + match self { + Self::Public => "Public instance", + Self::Private => "Private instance", + Self::Local => "Local/testing", + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect( + clippy::struct_excessive_bools, + reason = "preset defaults mirror independent setup toggles shown in the form" +)] +pub struct PresetDefaults { + pub site_name: &'static str, + pub board_slug: &'static str, + pub board_name: &'static str, + pub board_description: &'static str, + pub board_visibility: &'static str, + pub allow_uploads: bool, + pub allow_video: bool, + pub allow_audio: bool, + pub allow_pdf: bool, + pub allow_captcha: bool, + pub post_cooldown_secs: i64, + pub hide_nsfw_default: bool, +} + +#[must_use] +pub const fn preset_defaults(preset: SetupPreset) -> PresetDefaults { + match preset { + SetupPreset::Public => PresetDefaults { + site_name: "RustChan", + board_slug: "b", + board_name: "Random", + board_description: "General discussion", + board_visibility: "public", + allow_uploads: true, + allow_video: true, + allow_audio: false, + allow_pdf: false, + allow_captcha: true, + post_cooldown_secs: 10, + hide_nsfw_default: false, + }, + SetupPreset::Private => PresetDefaults { + site_name: "Private RustChan", + board_slug: "home", + board_name: "Home", + board_description: "Private board", + board_visibility: "view_password", + allow_uploads: true, + allow_video: false, + allow_audio: false, + allow_pdf: false, + allow_captcha: false, + post_cooldown_secs: 0, + hide_nsfw_default: true, + }, + SetupPreset::Local => PresetDefaults { + site_name: "Local RustChan", + board_slug: "test", + board_name: "Testing", + board_description: "Local testing board", + board_visibility: "public", + allow_uploads: true, + allow_video: true, + allow_audio: true, + allow_pdf: true, + allow_captcha: false, + post_cooldown_secs: 0, + hide_nsfw_default: false, + }, + } +} + +fn parse_preset(value: &str) -> SetupPreset { + match value.trim() { + "private" => SetupPreset::Private, + "local" => SetupPreset::Local, + _ => SetupPreset::Public, + } +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct SetupWizardForm { + #[serde(rename = "_csrf")] + pub csrf: Option, + pub preset: String, + pub site_name: String, + pub site_subtitle: Option, + pub default_theme: Option, + pub admin_username: Option, + pub admin_password: Option, + pub admin_password_confirm: Option, + pub admin_password_token: Option, + pub enable_tor: Option, + pub tor_only: Option, + pub public_url: Option, + pub https_cookies: Option, + pub behind_proxy: Option, + pub board_slug: String, + pub board_name: String, + pub board_description: Option, + pub board_nsfw: Option, + pub board_visibility: Option, + pub allow_posting: Option, + pub allow_uploads: Option, + pub allow_video: Option, + pub allow_audio: Option, + pub allow_pdf: Option, + pub allow_video_embeds: Option, + pub allow_thread_editing: Option, + pub allow_self_delete: Option, + pub allow_archive: Option, + pub image_limit_mib: String, + pub video_limit_mib: String, + pub audio_limit_mib: String, + pub pdf_limit_mib: String, + pub allow_captcha: Option, + pub captcha_type: Option, + pub post_cooldown_secs: Option, + pub homepage_new_thread_badges_enabled: Option, + pub homepage_new_reply_badges_enabled: Option, + pub thread_new_reply_badges_enabled: Option, + pub hide_nsfw_default: Option, + pub auto_backup_enabled: Option, + pub backup_retention: Option, + pub include_tor_keys_in_backups: Option, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +#[expect( + clippy::struct_excessive_bools, + reason = "parsed setup data carries independent persisted toggles from the review form" +)] +pub struct ParsedSetup { + pub preset: SetupPreset, + pub site_name: String, + pub site_subtitle: String, + pub default_theme: String, + pub admin_username: Option, + pub admin_password: Option, + pub board_slug: String, + pub board_name: String, + pub board_description: String, + pub board_nsfw: bool, + pub board_visibility: String, + pub allow_posting: bool, + pub allow_uploads: bool, + pub allow_video: bool, + pub allow_audio: bool, + pub allow_pdf: bool, + pub allow_video_embeds: bool, + pub allow_thread_editing: bool, + pub allow_self_delete: bool, + pub allow_archive: bool, + pub image_limit_bytes: i64, + pub video_limit_bytes: i64, + pub audio_limit_bytes: i64, + pub pdf_limit_bytes: i64, + pub allow_captcha: bool, + pub captcha_type: String, + pub post_cooldown_secs: i64, + pub homepage_new_thread_badges_enabled: bool, + pub homepage_new_reply_badges_enabled: bool, + pub thread_new_reply_badges_enabled: bool, + pub hide_nsfw_default: bool, + pub enable_tor: bool, + pub tor_only: bool, + pub public_url: String, + pub https_cookies: bool, + pub behind_proxy: bool, + pub auto_backup_interval_hours: u64, + pub backup_retention: u64, + pub include_tor_keys_in_backups: bool, +} + +#[must_use] +pub fn validate_board_slug(raw: &str) -> Option { + let slug = raw.trim().to_ascii_lowercase(); + let valid = !slug.is_empty() + && slug.len() <= 8 + && slug + .bytes() + .all(|byte| byte.is_ascii_lowercase() || byte.is_ascii_digit()); + valid.then_some(slug) +} + +#[must_use] +pub fn parse_upload_limit_mib(raw: &str) -> Option { + let mib = raw.trim().parse::().ok()?; + if !(1..=4096).contains(&mib) { + return None; + } + i64::try_from(mib.saturating_mul(MIB)).ok() +} + +#[must_use] +pub fn validate_password_confirmation(password: &str, confirmation: &str) -> bool { + password == confirmation && password.chars().count() >= 12 && password.chars().count() <= 1024 +} + +fn checkbox(value: Option<&str>) -> bool { + value.is_some_and(|value| matches!(value, "1" | "true" | "on")) +} + +fn checked(value: Option<&str>) -> &'static str { + if checkbox(value) { + " checked" + } else { + "" + } +} + +fn pending_admin_hash_signature(token: &str, password_hash_hex: &str) -> String { + crypto::sha256_hex( + format!( + "{}:setup-admin-password:{token}:{password_hash_hex}", + CONFIG.cookie_secret + ) + .as_bytes(), + ) +} + +fn make_pending_admin_hash_cookie( + token: &str, + password_hash: &str, + headers: &HeaderMap, + secure_context: crate::middleware::SecureCookieContext, +) -> Cookie<'static> { + let password_hash_hex = hex::encode(password_hash.as_bytes()); + let signature = pending_admin_hash_signature(token, &password_hash_hex); + let mut cookie = Cookie::new( + SETUP_PENDING_ADMIN_HASH_COOKIE, + format!("{token}:{password_hash_hex}:{signature}"), + ); + cookie.set_http_only(true); + cookie.set_same_site(SameSite::Lax); + cookie.set_path("/"); + cookie.set_secure(crate::handlers::admin::should_set_secure_cookie( + headers, + secure_context, + )); + cookie +} + +fn pending_admin_hash_from_cookie(jar: &CookieJar, token: Option<&str>) -> Result> { + let Some(token) = token.filter(|value| !value.is_empty()) else { + return Ok(None); + }; + let Some(cookie) = jar.get(SETUP_PENDING_ADMIN_HASH_COOKIE) else { + return Ok(None); + }; + let mut parts = cookie.value().splitn(3, ':'); + let Some(cookie_token) = parts.next() else { + return Ok(None); + }; + let Some(password_hash_hex) = parts.next() else { + return Ok(None); + }; + let Some(signature) = parts.next() else { + return Ok(None); + }; + if cookie_token != token { + return Ok(None); + } + let expected = pending_admin_hash_signature(token, password_hash_hex); + if signature != expected { + return Err(AppError::Forbidden( + "Setup admin secret token is invalid.".into(), + )); + } + let password_hash_bytes = hex::decode(password_hash_hex) + .map_err(|_error| AppError::BadRequest("Setup admin secret token is malformed.".into()))?; + let password_hash = String::from_utf8(password_hash_bytes).map_err(|_error| { + AppError::BadRequest("Setup admin secret token is not valid UTF-8.".into()) + })?; + Ok(Some(password_hash)) +} + +fn trimmed_limited(value: Option<&str>, max_chars: usize) -> String { + value + .unwrap_or_default() + .trim() + .chars() + .take(max_chars) + .collect() +} + +pub fn parse_setup_form( + form: &SetupWizardForm, + admin_count: i64, +) -> std::result::Result> { + parse_setup_form_inner(form, admin_count, false) +} + +fn parse_setup_form_inner( + form: &SetupWizardForm, + admin_count: i64, + admin_secret_available: bool, +) -> std::result::Result> { + let mut errors = Vec::new(); + let preset = parse_preset(&form.preset); + let defaults = preset_defaults(preset); + let site_name = form.site_name.trim().chars().take(64).collect::(); + if site_name.is_empty() { + errors.push("Site name is required.".to_owned()); + } + let admin_username = if admin_count == 0 { + let username = trimmed_limited(form.admin_username.as_deref(), 32); + let username_valid = (3..=32).contains(&username.len()) + && username + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'_' || byte == b'-'); + if !username_valid { + errors.push( + "Admin username must be 3-32 ASCII letters, numbers, underscores, or dashes." + .to_owned(), + ); + } + let password = form.admin_password.as_deref().unwrap_or_default(); + let confirmation = form.admin_password_confirm.as_deref().unwrap_or_default(); + if !admin_secret_available && !validate_password_confirmation(password, confirmation) { + errors.push( + "Admin password must be at least 12 characters and match confirmation.".to_owned(), + ); + } + Some(username) + } else { + None + }; + let admin_password = if admin_count == 0 { + form.admin_password.clone() + } else { + None + }; + let board_slug = validate_board_slug(&form.board_slug).unwrap_or_else(|| { + errors.push("Board slug must be 1-8 lowercase letters or digits.".to_owned()); + String::new() + }); + let board_name = form.board_name.trim().chars().take(64).collect::(); + if board_name.is_empty() { + errors.push("Board name is required.".to_owned()); + } + let board_visibility = match form + .board_visibility + .as_deref() + .unwrap_or(defaults.board_visibility) + { + "public" | "view_password" | "post_password" => form + .board_visibility + .clone() + .unwrap_or_else(|| defaults.board_visibility.to_owned()), + _ => { + errors.push("Board visibility mode is invalid.".to_owned()); + "public".to_owned() + } + }; + let image_limit_bytes = parse_upload_limit_mib(&form.image_limit_mib); + let video_limit_bytes = parse_upload_limit_mib(&form.video_limit_mib); + let audio_limit_bytes = parse_upload_limit_mib(&form.audio_limit_mib); + let pdf_limit_bytes = parse_upload_limit_mib(&form.pdf_limit_mib); + if image_limit_bytes.is_none() + || video_limit_bytes.is_none() + || audio_limit_bytes.is_none() + || pdf_limit_bytes.is_none() + { + errors.push("Upload limits must be whole MiB values from 1 through 4096.".to_owned()); + } + if !errors.is_empty() { + return Err(errors); + } + let post_cooldown_secs = form + .post_cooldown_secs + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(defaults.post_cooldown_secs) + .clamp(0, 3600); + let backup_retention = form + .backup_retention + .as_deref() + .and_then(|value| value.trim().parse::().ok()) + .unwrap_or(1) + .clamp(1, 1000); + Ok(ParsedSetup { + preset, + site_name, + site_subtitle: trimmed_limited(form.site_subtitle.as_deref(), 128), + default_theme: form + .default_theme + .as_deref() + .map(db::sanitize_theme_slug) + .filter(|theme| !theme.is_empty()) + .unwrap_or_else(|| crate::theme::HARD_DEFAULT_THEME.to_owned()), + admin_username, + admin_password, + board_slug, + board_name, + board_description: trimmed_limited(form.board_description.as_deref(), 256), + board_nsfw: checkbox(form.board_nsfw.as_deref()), + board_visibility, + allow_posting: checkbox(form.allow_posting.as_deref()), + allow_uploads: checkbox(form.allow_uploads.as_deref()), + allow_video: checkbox(form.allow_video.as_deref()), + allow_audio: checkbox(form.allow_audio.as_deref()), + allow_pdf: checkbox(form.allow_pdf.as_deref()), + allow_video_embeds: checkbox(form.allow_video_embeds.as_deref()), + allow_thread_editing: checkbox(form.allow_thread_editing.as_deref()), + allow_self_delete: checkbox(form.allow_self_delete.as_deref()), + allow_archive: checkbox(form.allow_archive.as_deref()), + image_limit_bytes: image_limit_bytes.unwrap_or(8 * MIB_I64), + video_limit_bytes: video_limit_bytes.unwrap_or(50 * MIB_I64), + audio_limit_bytes: audio_limit_bytes.unwrap_or(150 * MIB_I64), + pdf_limit_bytes: pdf_limit_bytes.unwrap_or(8 * MIB_I64), + allow_captcha: checkbox(form.allow_captcha.as_deref()), + captcha_type: form + .captcha_type + .as_deref() + .filter(|value| matches!(*value, "builtin" | "disabled")) + .unwrap_or("builtin") + .to_owned(), + post_cooldown_secs, + homepage_new_thread_badges_enabled: checkbox( + form.homepage_new_thread_badges_enabled.as_deref(), + ), + homepage_new_reply_badges_enabled: checkbox( + form.homepage_new_reply_badges_enabled.as_deref(), + ), + thread_new_reply_badges_enabled: checkbox(form.thread_new_reply_badges_enabled.as_deref()), + hide_nsfw_default: checkbox(form.hide_nsfw_default.as_deref()), + enable_tor: checkbox(form.enable_tor.as_deref()), + tor_only: checkbox(form.tor_only.as_deref()), + public_url: trimmed_limited(form.public_url.as_deref(), 256), + https_cookies: checkbox(form.https_cookies.as_deref()), + behind_proxy: checkbox(form.behind_proxy.as_deref()), + auto_backup_interval_hours: if checkbox(form.auto_backup_enabled.as_deref()) { + 24 + } else { + 0 + }, + backup_retention, + include_tor_keys_in_backups: checkbox(form.include_tor_keys_in_backups.as_deref()), + }) +} + +fn ensure_setup_csrf( + jar: CookieJar, + headers: &HeaderMap, + secure_context: crate::middleware::SecureCookieContext, +) -> (CookieJar, String) { + let token = jar + .get("csrf_token") + .map(|cookie| cookie.value().to_owned()) + .filter(|value| !value.is_empty()) + .unwrap_or_else(crypto::new_csrf_token); + let mut cookie = Cookie::new("csrf_token", token.clone()); + cookie.set_http_only(false); + cookie.set_same_site(SameSite::Lax); + cookie.set_path("/"); + cookie.set_secure(crate::handlers::admin::should_set_secure_cookie( + headers, + secure_context, + )); + ( + jar.add(cookie), + crypto::make_scoped_csrf_form_token(&token, &CONFIG.cookie_secret, SETUP_CSRF_SCOPE), + ) +} + +fn validate_setup_csrf( + jar: &CookieJar, + headers: &HeaderMap, + peer: Option, + token: Option<&str>, +) -> Result<()> { + let csrf_cookie = jar.get("csrf_token").map(Cookie::value); + let csrf_valid = crate::middleware::validate_signed_csrf( + csrf_cookie, + Some(SETUP_CSRF_SCOPE), + token.unwrap_or(""), + ); + crate::handlers::admin::require_same_origin_or_valid_csrf(headers, peer, csrf_valid)?; + if csrf_valid { + Ok(()) + } else { + Err(AppError::Forbidden("CSRF token mismatch.".into())) + } +} + +fn admin_session_id(jar: &CookieJar) -> Option { + jar.get(crate::handlers::board::ADMIN_SESSION_COOKIE) + .map(|cookie| cookie.value().to_owned()) +} + +async fn load_setup_state( + state: &AppState, + session_id: Option, +) -> Result<(db::SetupState, bool)> { + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<(db::SetupState, bool)> { + let conn = pool.get()?; + let setup_state = db::setup_state(&conn)?; + if !setup_state.is_available() { + let message = if setup_state.completed { + "Setup is already complete." + } else { + "Setup wizard is not available." + }; + return Err(AppError::NotFound(message.into())); + } + let is_admin = session_id + .as_deref() + .is_some_and(|sid| db::get_session(&conn, sid).ok().flatten().is_some()); + if setup_state.requires_admin_auth() && !is_admin { + return Err(AppError::Forbidden( + "Current admin authentication is required to reopen setup.".into(), + )); + } + Ok((setup_state, is_admin)) + } + }) + .await + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))? +} + +pub async fn setup_get( + State(state): State, + Query(query): Query, + jar: CookieJar, + headers: HeaderMap, + secure_context: crate::middleware::SecureCookieContext, +) -> Result { + let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?; + let current_theme = crate::handlers::board::current_theme_from_jar(&jar); + let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context); + let form = + SetupWizardForm::defaults_for(parse_preset(query.preset.as_deref().unwrap_or("public"))); + let body = setup_form_page( + &csrf, + setup_state, + &form, + &[], + request_transport_warning(&headers, secure_context).as_deref(), + &state, + current_theme.as_deref(), + ); + Ok((jar, Html(body)).into_response()) +} + +#[derive(Deserialize)] +pub struct SetupQuery { + preset: Option, +} + +pub async fn setup_review( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + secure_context: crate::middleware::SecureCookieContext, + Form(form): Form, +) -> Result { + let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?; + let current_theme = crate::handlers::board::current_theme_from_jar(&jar); + validate_setup_csrf(&jar, &headers, secure_context.peer, form.csrf.as_deref())?; + let parsed = match parse_setup_form(&form, setup_state.admin_count) { + Ok(parsed) => parsed, + Err(errors) => { + let (jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context); + return Ok(( + jar, + ( + StatusCode::BAD_REQUEST, + Html(setup_form_page( + &csrf, + setup_state, + &form, + &errors, + request_transport_warning(&headers, secure_context).as_deref(), + &state, + current_theme.as_deref(), + )), + ), + ) + .into_response()); + } + }; + let (mut jar, csrf) = ensure_setup_csrf(jar, &headers, secure_context); + let mut review_form = form.clone(); + if setup_state.admin_count == 0 { + let password = parsed + .admin_password + .as_deref() + .ok_or_else(|| AppError::BadRequest("Initial admin password is required.".into()))?; + let password_hash = crypto::hash_password(password)?; + let token = crypto::random_hex(32); + jar = jar.add(make_pending_admin_hash_cookie( + &token, + &password_hash, + &headers, + secure_context, + )); + review_form.admin_password = None; + review_form.admin_password_confirm = None; + review_form.admin_password_token = Some(token); + } + Ok(( + jar, + Html(setup_review_page( + &csrf, + setup_state, + &review_form, + &parsed, + current_theme.as_deref(), + )), + ) + .into_response()) +} + +pub async fn setup_finish( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + secure_context: crate::middleware::SecureCookieContext, + Form(form): Form, +) -> Result { + let (setup_state, _is_admin) = load_setup_state(&state, admin_session_id(&jar)).await?; + validate_setup_csrf(&jar, &headers, secure_context.peer, form.csrf.as_deref())?; + let pending_admin_hash = + pending_admin_hash_from_cookie(&jar, form.admin_password_token.as_deref())?; + let parsed = + parse_setup_form_inner(&form, setup_state.admin_count, pending_admin_hash.is_some()) + .map_err(|errors| AppError::BadRequest(errors.join(" ")))?; + let board_slug = parsed.board_slug.clone(); + let auto_backup_settings = state.auto_full_backup_settings.clone(); + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let mut conn = pool.get()?; + let tx = conn.transaction()?; + db::ensure_setup_available(&tx).map_err(|_setup_state_error| { + AppError::NotFound("Setup wizard is not available.".into()) + })?; + if db::admin_count(&tx)? == 0 { + let username = parsed.admin_username.as_deref().ok_or_else(|| { + AppError::BadRequest("Initial admin username is required.".into()) + })?; + let password_hash = if let Some(hash) = pending_admin_hash.as_deref() { + hash.to_owned() + } else { + let password = parsed.admin_password.as_deref().ok_or_else(|| { + AppError::BadRequest("Initial admin password is required.".into()) + })?; + crypto::hash_password(password)? + }; + db::create_admin(&tx, username, &password_hash)?; + } + if db::board_slug_exists(&tx, &parsed.board_slug)? { + return Err(AppError::Conflict(format!( + "Board /{}/ already exists.", + parsed.board_slug + ))); + } + let board_id = db::create_board_with_media_flags( + &tx, + &parsed.board_slug, + &parsed.board_name, + &parsed.board_description, + parsed.board_nsfw, + parsed.allow_uploads, + parsed.allow_uploads && parsed.allow_video, + parsed.allow_uploads && parsed.allow_audio, + )?; + tx.execute( + "UPDATE boards SET + max_threads = ?1, max_archived_threads = ?2, bump_limit = ?3, + max_image_size = ?4, max_video_size = ?5, max_audio_size = ?6, + max_pdf_size = ?7, allow_pdf = ?8, allow_any_files = 0, allow_tripcodes = 1, + edit_window_secs = 0, allow_editing = ?9, allow_self_delete = ?10, + allow_archive = ?11, allow_video_embeds = ?12, allow_captcha = ?13, + show_poster_ids = 1, collapse_greentext = 0, post_cooldown_secs = ?14, + default_theme = ?15, banner_mode = 'inherit', access_mode = ?16, + access_password_hash = '' + WHERE id = ?17", + rusqlite::params![ + if parsed.allow_posting { 150 } else { 0 }, + 150, + 500, + parsed.image_limit_bytes, + parsed.video_limit_bytes, + parsed.audio_limit_bytes, + parsed.pdf_limit_bytes, + i32::from(parsed.allow_uploads && parsed.allow_pdf), + i32::from(parsed.allow_thread_editing), + i32::from(parsed.allow_self_delete), + i32::from(parsed.allow_archive), + i32::from(parsed.allow_video_embeds), + i32::from(parsed.allow_captcha && parsed.captcha_type == "builtin"), + parsed.post_cooldown_secs, + parsed.default_theme, + parsed.board_visibility, + board_id, + ], + )?; + db::set_site_setting(&tx, "site_name", &parsed.site_name)?; + db::set_site_setting(&tx, "site_subtitle", &parsed.site_subtitle)?; + db::set_site_setting(&tx, "default_theme", &parsed.default_theme)?; + db::set_site_setting( + &tx, + "homepage_new_thread_badges_enabled", + if parsed.homepage_new_thread_badges_enabled { + "1" + } else { + "0" + }, + )?; + db::set_site_setting( + &tx, + "homepage_new_reply_badges_enabled", + if parsed.homepage_new_reply_badges_enabled { + "1" + } else { + "0" + }, + )?; + db::set_site_setting( + &tx, + "thread_new_reply_badges_enabled", + if parsed.thread_new_reply_badges_enabled { + "1" + } else { + "0" + }, + )?; + db::set_site_setting( + &tx, + "default_hide_nsfw_boards", + if parsed.hide_nsfw_default { "1" } else { "0" }, + )?; + db::set_site_setting(&tx, "setup_public_url", &parsed.public_url)?; + db::set_site_setting( + &tx, + "setup_backup_destination", + &crate::config::full_backups_dir().display().to_string(), + )?; + db::set_site_setting( + &tx, + "setup_pdf_upload_limit_bytes", + &parsed.pdf_limit_bytes.to_string(), + )?; + crate::config::update_settings_file_setup(&crate::config::SetupSettingsFileUpdate { + forum_name: &parsed.site_name, + site_subtitle: &parsed.site_subtitle, + homepage_new_thread_badges_enabled: parsed.homepage_new_thread_badges_enabled, + homepage_new_reply_badges_enabled: parsed.homepage_new_reply_badges_enabled, + thread_new_reply_badges_enabled: parsed.thread_new_reply_badges_enabled, + default_theme: &parsed.default_theme, + auto_full_backup_interval_hours: parsed.auto_backup_interval_hours, + auto_full_backup_copies_to_keep: parsed.backup_retention, + auto_full_backup_include_tor_hidden_service_keys: parsed + .include_tor_keys_in_backups, + auto_full_backup_storage_mode: "directory", + auto_full_backup_split_zip_part_size_gib: + crate::handlers::admin::backup::split_zip_part_size_gib( + CONFIG.auto_full_backup_split_zip_part_size_bytes, + ), + runtime: crate::config::SetupRuntimeSettingsUpdate { + enable_tor_support: parsed.enable_tor, + tor_only: parsed.tor_only, + behind_proxy: parsed.behind_proxy, + https_cookies: parsed.https_cookies, + max_image_size_mb: u64::try_from(parsed.image_limit_bytes / MIB_I64) + .unwrap_or(8), + max_video_size_mb: u64::try_from(parsed.video_limit_bytes / MIB_I64) + .unwrap_or(50), + max_audio_size_mb: u64::try_from(parsed.audio_limit_bytes / MIB_I64) + .unwrap_or(150), + }, + })?; + db::mark_setup_complete(&tx)?; + tx.commit()?; + + templates::set_live_site_name(&parsed.site_name); + templates::set_live_site_subtitle(&parsed.site_subtitle); + db::sync_live_theme_state(&conn)?; + templates::set_live_boards(db::get_all_boards(&conn)?); + auto_backup_settings.update( + parsed.auto_backup_interval_hours, + parsed.backup_retention, + parsed.include_tor_keys_in_backups, + "directory", + CONFIG.auto_full_backup_split_zip_part_size_bytes, + ); + Ok(()) + } + }) + .await + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??; + + let jar = jar.remove(Cookie::from(SETUP_PENDING_ADMIN_HASH_COOKIE)); + Ok((jar, Redirect::to(&format!("/{board_slug}"))).into_response()) +} + +#[derive(Deserialize)] +pub struct ReopenSetupForm { + #[serde(rename = "_csrf")] + csrf: Option, +} + +pub async fn admin_reopen_setup( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, + Form(form): Form, +) -> Result { + let session_id = admin_session_id(&jar); + crate::handlers::admin::require_admin_post_origin_and_csrf( + &jar, + &headers, + Some(peer), + form.csrf.as_deref(), + )?; + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + let admin_id = + crate::handlers::admin::require_admin_session_sid(&conn, session_id.as_deref())?; + db::reopen_setup(&conn, admin_id)?; + Ok(()) + } + }) + .await + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??; + Ok(Redirect::to("/setup").into_response()) +} + +pub async fn admin_close_setup( + State(state): State, + jar: CookieJar, + headers: HeaderMap, + axum::extract::ConnectInfo(peer): axum::extract::ConnectInfo, + Form(form): Form, +) -> Result { + let session_id = admin_session_id(&jar); + crate::handlers::admin::require_admin_post_origin_and_csrf( + &jar, + &headers, + Some(peer), + form.csrf.as_deref(), + )?; + tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || -> Result<()> { + let conn = pool.get()?; + crate::handlers::admin::require_admin_session_sid(&conn, session_id.as_deref())?; + db::close_reopened_setup(&conn)?; + Ok(()) + } + }) + .await + .map_err(|error| AppError::Internal(anyhow::anyhow!(error)))??; + Ok(Redirect::to( + "/admin/panel?flash=Setup+wizard+closed.&open=database-maintenance#database-maintenance", + ) + .into_response()) +} + +impl SetupWizardForm { + #[must_use] + pub fn defaults_for(preset: SetupPreset) -> Self { + let defaults = preset_defaults(preset); + Self { + csrf: None, + preset: preset.as_str().to_owned(), + site_name: defaults.site_name.to_owned(), + site_subtitle: Some("select board to proceed".to_owned()), + default_theme: Some(crate::theme::HARD_DEFAULT_THEME.to_owned()), + admin_username: Some("admin".to_owned()), + admin_password: None, + admin_password_confirm: None, + admin_password_token: None, + enable_tor: Some("1".to_owned()), + tor_only: None, + public_url: None, + https_cookies: None, + behind_proxy: None, + board_slug: defaults.board_slug.to_owned(), + board_name: defaults.board_name.to_owned(), + board_description: Some(defaults.board_description.to_owned()), + board_nsfw: None, + board_visibility: Some(defaults.board_visibility.to_owned()), + allow_posting: Some("1".to_owned()), + allow_uploads: defaults.allow_uploads.then(|| "1".to_owned()), + allow_video: defaults.allow_video.then(|| "1".to_owned()), + allow_audio: defaults.allow_audio.then(|| "1".to_owned()), + allow_pdf: defaults.allow_pdf.then(|| "1".to_owned()), + allow_video_embeds: Some("1".to_owned()), + allow_thread_editing: Some("1".to_owned()), + allow_self_delete: Some("1".to_owned()), + allow_archive: Some("1".to_owned()), + image_limit_mib: "8".to_owned(), + video_limit_mib: "50".to_owned(), + audio_limit_mib: "150".to_owned(), + pdf_limit_mib: "8".to_owned(), + allow_captcha: defaults.allow_captcha.then(|| "1".to_owned()), + captcha_type: Some("builtin".to_owned()), + post_cooldown_secs: Some(defaults.post_cooldown_secs.to_string()), + homepage_new_thread_badges_enabled: Some("1".to_owned()), + homepage_new_reply_badges_enabled: Some("1".to_owned()), + thread_new_reply_badges_enabled: Some("1".to_owned()), + hide_nsfw_default: defaults.hide_nsfw_default.then(|| "1".to_owned()), + auto_backup_enabled: None, + backup_retention: Some("1".to_owned()), + include_tor_keys_in_backups: None, + } + } +} + +fn request_transport_warning( + headers: &HeaderMap, + context: crate::middleware::SecureCookieContext, +) -> Option { + let secure_now = crate::handlers::admin::should_set_secure_cookie(headers, context); + let host = headers + .get(header::HOST) + .and_then(|value| value.to_str().ok()) + .unwrap_or("this host"); + if CONFIG.https_cookies + && !secure_now + && !host.starts_with("localhost") + && !host.starts_with("127.0.0.1") + { + Some("Secure cookies are enabled in configuration, but this request is not arriving as HTTPS. Admin login may fail until TLS or trusted proxy headers are configured.".to_owned()) + } else { + None + } +} + +fn setup_form_page( + csrf: &str, + state: db::SetupState, + form: &SetupWizardForm, + errors: &[String], + transport_warning: Option<&str>, + app_state: &AppState, + current_theme: Option<&str>, +) -> String { + let mut alerts = String::new(); + if state.requires_admin_auth() { + alerts.push_str(r#"
Setup was reopened by an admin. Existing admin credentials will not be replaced.
"#); + } + if let Some(warning) = transport_warning { + let _ = write!( + alerts, + r#"
{}
"#, + escape_html(warning) + ); + } + for error in errors { + let _ = write!( + alerts, + r#""#, + escape_html(error) + ); + } + let admin_fields = if state.admin_count == 0 { + r#"
+

3. Admin account

+
+ + + +
+
"# + .to_owned() + } else { + r#"

3. Admin account

An admin account already exists. Continue as the currently authenticated admin; this wizard will not replace credentials.

"#.to_owned() + }; + let ffmpeg = if app_state.ffmpeg_available { + "detected" + } else { + "missing" + }; + let ffprobe = if app_state.ffprobe_available { + "detected" + } else { + "missing" + }; + let body = format!( + r#"
+
+

RustChan setup

+

Configure this local runtime before opening the instance to users. All controls work without JavaScript.

+
+{alerts} +
+ +

1. Instance mode

{preset_options}
+

2. Site basics

+ + + +

About, rules, and contact links can be added later from supported site surfaces.

+{admin_fields} +

4. Network / Tor

+ + + + + +
+

5. Default board

+ + + + + + + + + +
+

6. Uploads and media

+

ffmpeg: {ffmpeg}; ffprobe: {ffprobe}. PDF uploads use the PDF limit shown here; other file types use their matching media limits.

+
+ + + + + + + + + +
+

7. Anti-spam and privacy

+ + + + + + + +
+

8. Backups

+

Default destination: {backup_dir}. Automatic backups stay disabled unless enabled here.

+
+ + + +

Tor keys are excluded by default. Include them only if a backup should restore the same onion identity.

+

9. Review and finish

+

Review the next page before anything is written. Setup is marked complete only after all database writes succeed.

+ +
+
+
"#, + alerts = alerts, + csrf = escape_html(csrf), + preset_options = preset_options(&form.preset), + site_name = escape_html(&form.site_name), + site_subtitle = escape_html(form.site_subtitle.as_deref().unwrap_or_default()), + default_theme = escape_html( + form.default_theme + .as_deref() + .unwrap_or(crate::theme::HARD_DEFAULT_THEME), + ), + admin_fields = admin_fields, + public_url = escape_html(form.public_url.as_deref().unwrap_or_default()), + enable_tor = checked(form.enable_tor.as_deref()), + tor_only = checked(form.tor_only.as_deref()), + https_cookies = checked(form.https_cookies.as_deref()), + behind_proxy = checked(form.behind_proxy.as_deref()), + board_slug = escape_html(&form.board_slug), + board_name = escape_html(&form.board_name), + board_description = escape_html(form.board_description.as_deref().unwrap_or_default()), + visibility_options = + visibility_options(form.board_visibility.as_deref().unwrap_or("public")), + allow_posting = checked(form.allow_posting.as_deref()), + board_nsfw = checked(form.board_nsfw.as_deref()), + allow_thread_editing = checked(form.allow_thread_editing.as_deref()), + allow_self_delete = checked(form.allow_self_delete.as_deref()), + allow_archive = checked(form.allow_archive.as_deref()), + ffmpeg = ffmpeg, + ffprobe = ffprobe, + allow_uploads = checked(form.allow_uploads.as_deref()), + allow_video = checked(form.allow_video.as_deref()), + allow_audio = checked(form.allow_audio.as_deref()), + allow_pdf = checked(form.allow_pdf.as_deref()), + allow_video_embeds = checked(form.allow_video_embeds.as_deref()), + image_limit_mib = escape_html(&form.image_limit_mib), + video_limit_mib = escape_html(&form.video_limit_mib), + audio_limit_mib = escape_html(&form.audio_limit_mib), + pdf_limit_mib = escape_html(&form.pdf_limit_mib), + allow_captcha = checked(form.allow_captcha.as_deref()), + post_cooldown_secs = escape_html(form.post_cooldown_secs.as_deref().unwrap_or("0")), + homepage_new_thread_badges_enabled = + checked(form.homepage_new_thread_badges_enabled.as_deref()), + homepage_new_reply_badges_enabled = + checked(form.homepage_new_reply_badges_enabled.as_deref()), + thread_new_reply_badges_enabled = checked(form.thread_new_reply_badges_enabled.as_deref()), + hide_nsfw_default = checked(form.hide_nsfw_default.as_deref()), + backup_dir = escape_html(&crate::config::full_backups_dir().display().to_string()), + auto_backup_enabled = checked(form.auto_backup_enabled.as_deref()), + backup_retention = escape_html(form.backup_retention.as_deref().unwrap_or("1")), + include_tor_keys_in_backups = checked(form.include_tor_keys_in_backups.as_deref()), + ); + let boards = templates::live_boards(); + templates::base_layout( + "setup", + None, + &body, + csrf, + boards.as_ref(), + current_theme, + form.default_theme.as_deref(), + false, + "/setup", + ) +} + +fn setup_review_page( + csrf: &str, + state: db::SetupState, + form: &SetupWizardForm, + parsed: &ParsedSetup, + current_theme: Option<&str>, +) -> String { + let hidden = hidden_form_fields(csrf, form); + let admin_line = if state.admin_count == 0 { + format!( + "Create initial admin account: {}", + escape_html(parsed.admin_username.as_deref().unwrap_or("admin")) + ) + } else { + "Keep existing admin credentials; current admin authorization required.".to_owned() + }; + let body = format!( + r#"
+

Review setup

Confirm these settings before RustChan writes setup state.

+
+

Summary

+
+
Preset
{preset}
+
Site
{site}
+
Admin
{admin}
+
Board
/{slug}/ - {board}
+
Uploads
image {image} MiB, video {video} MiB, audio {audio} MiB, PDF {pdf} MiB
+
Network
Tor {tor}; HTTPS cookies {https_cookies}; proxy {proxy}
+
Backups
{backup}
+
+
+{hidden} + +edit settings +
+
+
"#, + preset = parsed.preset.label(), + site = escape_html(&parsed.site_name), + admin = admin_line, + slug = escape_html(&parsed.board_slug), + board = escape_html(&parsed.board_name), + image = parsed.image_limit_bytes / i64::try_from(MIB).unwrap_or(1), + video = parsed.video_limit_bytes / i64::try_from(MIB).unwrap_or(1), + audio = parsed.audio_limit_bytes / i64::try_from(MIB).unwrap_or(1), + pdf = parsed.pdf_limit_bytes / i64::try_from(MIB).unwrap_or(1), + tor = if parsed.enable_tor { + "enabled" + } else { + "disabled" + }, + https_cookies = if parsed.https_cookies { + "enabled when HTTPS" + } else { + "unchanged" + }, + proxy = if parsed.behind_proxy { + "configured after restart" + } else { + "off" + }, + backup = if parsed.auto_backup_interval_hours == 0 { + "automatic backups disabled".to_owned() + } else { + format!( + "every {} hours, keep {}", + parsed.auto_backup_interval_hours, parsed.backup_retention + ) + }, + hidden = hidden, + ); + let boards = templates::live_boards(); + templates::base_layout( + "review setup", + None, + &body, + csrf, + boards.as_ref(), + current_theme, + Some(&parsed.default_theme), + false, + "/setup", + ) +} + +fn preset_options(selected: &str) -> String { + let mut out = String::new(); + for preset in [ + SetupPreset::Public, + SetupPreset::Private, + SetupPreset::Local, + ] { + let _ = write!( + out, + r#""#, + value = preset.as_str(), + checked = if selected == preset.as_str() { + " checked" + } else { + "" + }, + label = preset.label(), + ); + } + out +} + +fn visibility_options(selected: &str) -> String { + let mut out = String::new(); + for (value, label) in [ + ("public", "Public"), + ("view_password", "Require password to view"), + ("post_password", "Require password to post"), + ] { + let _ = write!( + out, + r#""#, + selected_attr = if selected == value { " selected" } else { "" }, + ); + } + out +} + +fn hidden_field(name: &str, value: &str) -> String { + format!( + r#""#, + name = escape_html(name), + value = escape_html(value) + ) +} + +fn hidden_checkbox(name: &str, value: Option<&str>) -> String { + if checkbox(value) { + hidden_field(name, "1") + } else { + String::new() + } +} + +fn hidden_form_fields(csrf: &str, form: &SetupWizardForm) -> String { + let mut out = String::new(); + out.push_str(&hidden_field("_csrf", csrf)); + for (name, value) in [ + ("preset", form.preset.as_str()), + ("site_name", form.site_name.as_str()), + ( + "site_subtitle", + form.site_subtitle.as_deref().unwrap_or_default(), + ), + ( + "default_theme", + form.default_theme.as_deref().unwrap_or_default(), + ), + ( + "admin_username", + form.admin_username.as_deref().unwrap_or_default(), + ), + ( + "admin_password_token", + form.admin_password_token.as_deref().unwrap_or_default(), + ), + ("public_url", form.public_url.as_deref().unwrap_or_default()), + ("board_slug", form.board_slug.as_str()), + ("board_name", form.board_name.as_str()), + ( + "board_description", + form.board_description.as_deref().unwrap_or_default(), + ), + ( + "board_visibility", + form.board_visibility.as_deref().unwrap_or("public"), + ), + ("image_limit_mib", form.image_limit_mib.as_str()), + ("video_limit_mib", form.video_limit_mib.as_str()), + ("audio_limit_mib", form.audio_limit_mib.as_str()), + ("pdf_limit_mib", form.pdf_limit_mib.as_str()), + ( + "captcha_type", + form.captcha_type.as_deref().unwrap_or("builtin"), + ), + ( + "post_cooldown_secs", + form.post_cooldown_secs.as_deref().unwrap_or("0"), + ), + ( + "backup_retention", + form.backup_retention.as_deref().unwrap_or("1"), + ), + ] { + out.push_str(&hidden_field(name, value)); + } + for (name, value) in [ + ("enable_tor", form.enable_tor.as_deref()), + ("tor_only", form.tor_only.as_deref()), + ("https_cookies", form.https_cookies.as_deref()), + ("behind_proxy", form.behind_proxy.as_deref()), + ("board_nsfw", form.board_nsfw.as_deref()), + ("allow_posting", form.allow_posting.as_deref()), + ("allow_uploads", form.allow_uploads.as_deref()), + ("allow_video", form.allow_video.as_deref()), + ("allow_audio", form.allow_audio.as_deref()), + ("allow_pdf", form.allow_pdf.as_deref()), + ("allow_video_embeds", form.allow_video_embeds.as_deref()), + ("allow_thread_editing", form.allow_thread_editing.as_deref()), + ("allow_self_delete", form.allow_self_delete.as_deref()), + ("allow_archive", form.allow_archive.as_deref()), + ("allow_captcha", form.allow_captcha.as_deref()), + ( + "homepage_new_thread_badges_enabled", + form.homepage_new_thread_badges_enabled.as_deref(), + ), + ( + "homepage_new_reply_badges_enabled", + form.homepage_new_reply_badges_enabled.as_deref(), + ), + ( + "thread_new_reply_badges_enabled", + form.thread_new_reply_badges_enabled.as_deref(), + ), + ("hide_nsfw_default", form.hide_nsfw_default.as_deref()), + ("auto_backup_enabled", form.auto_backup_enabled.as_deref()), + ( + "include_tor_keys_in_backups", + form.include_tor_keys_in_backups.as_deref(), + ), + ] { + out.push_str(&hidden_checkbox(name, value)); + } + out +} + +#[cfg(test)] +mod tests { + use super::*; + use axum::{ + body::{to_bytes, Body}, + http::{header, Request, StatusCode}, + routing::{get, post}, + Router, + }; + use tower::ServiceExt as _; + + #[test] + fn setup_validation_rejects_bad_slug_and_password_mismatch() { + let mut form = SetupWizardForm::defaults_for(SetupPreset::Public); + form.board_slug = "../bad".to_owned(); + form.admin_password = Some("long-enough-password".to_owned()); + form.admin_password_confirm = Some("different-password".to_owned()); + + let errors = parse_setup_form(&form, 0).expect_err("validation errors"); + + assert!(errors.iter().any(|error| error.contains("Board slug"))); + assert!(errors.iter().any(|error| error.contains("Admin password"))); + } + + #[test] + fn upload_limit_parser_requires_clear_mib_units() { + assert_eq!(parse_upload_limit_mib("8"), Some(8 * MIB_I64)); + assert_eq!(parse_upload_limit_mib("0"), None); + assert_eq!(parse_upload_limit_mib("nope"), None); + } + + #[test] + fn preset_defaults_are_conservative_for_public_instances() { + let defaults = preset_defaults(SetupPreset::Public); + assert!(defaults.allow_uploads); + assert!(!defaults.allow_audio); + assert!(!defaults.allow_pdf); + assert!(defaults.allow_captcha); + } + + fn setup_form_body(csrf: &str, board_slug: &str, password: Option<&str>) -> String { + let mut fields = vec![ + ("_csrf", csrf.to_owned()), + ("preset", "public".to_owned()), + ("site_name", "Test RustChan".to_owned()), + ("site_subtitle", String::new()), + ("default_theme", crate::theme::HARD_DEFAULT_THEME.to_owned()), + ("admin_username", "admin".to_owned()), + ("board_slug", board_slug.to_owned()), + ("board_name", "Test Board".to_owned()), + ("board_description", String::new()), + ("board_visibility", "public".to_owned()), + ("allow_posting", "1".to_owned()), + ("allow_uploads", "1".to_owned()), + ("allow_video", "1".to_owned()), + ("allow_pdf", "1".to_owned()), + ("allow_video_embeds", "1".to_owned()), + ("allow_thread_editing", "1".to_owned()), + ("allow_self_delete", "1".to_owned()), + ("allow_archive", "1".to_owned()), + ("image_limit_mib", "8".to_owned()), + ("video_limit_mib", "50".to_owned()), + ("audio_limit_mib", "150".to_owned()), + ("pdf_limit_mib", "7".to_owned()), + ("captcha_type", "builtin".to_owned()), + ("post_cooldown_secs", "0".to_owned()), + ("homepage_new_thread_badges_enabled", "1".to_owned()), + ("homepage_new_reply_badges_enabled", "1".to_owned()), + ("thread_new_reply_badges_enabled", "1".to_owned()), + ("backup_retention", "1".to_owned()), + ]; + if let Some(password) = password { + fields.push(("admin_password", password.to_owned())); + fields.push(("admin_password_confirm", password.to_owned())); + } + fields + .into_iter() + .map(|(key, value)| format!("{key}={value}")) + .collect::>() + .join("&") + } + + fn setup_csrf_pair() -> (String, String) { + let raw = "csrf123".to_owned(); + let form = + crypto::make_scoped_csrf_form_token(&raw, &CONFIG.cookie_secret, SETUP_CSRF_SCOPE); + (raw, form) + } + + fn install_setup_theme_test_state() { + crate::templates::set_live_default_theme("forest"); + crate::templates::set_live_themes(vec![ + crate::models::Theme { + slug: "forest".to_owned(), + display_name: "Forest".to_owned(), + description: "Forest theme".to_owned(), + swatch_hex: "#7ab84e".to_owned(), + enabled: true, + sort_order: 1, + is_builtin: true, + custom_css: String::new(), + }, + crate::models::Theme { + slug: "blue-sky".to_owned(), + display_name: "Blue Sky".to_owned(), + description: "Bright theme".to_owned(), + swatch_hex: "#66aaff".to_owned(), + enabled: true, + sort_order: 2, + is_builtin: true, + custom_css: String::new(), + }, + ]); + } + + #[tokio::test] + async fn initialized_instance_blocks_setup_route() { + let state = crate::test_support::app_state(); + { + let conn = state.db.get().expect("conn"); + crate::db::create_admin(&conn, "admin", "hash").expect("admin"); + } + let app = Router::new() + .route("/setup", get(setup_get)) + .with_state(state); + + let response = app + .oneshot( + Request::builder() + .uri("/setup") + .header(header::HOST, "localhost") + .extension(crate::test_support::connect_info()) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let body = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(body.contains("Setup wizard is not available")); + } + + #[tokio::test] + async fn initialized_instance_blocks_setup_post_routes() { + let state = crate::test_support::app_state(); + { + let conn = state.db.get().expect("conn"); + crate::db::mark_setup_complete(&conn).expect("complete"); + } + let app = Router::new() + .route("/setup/review", post(setup_review)) + .route("/setup/finish", post(setup_finish)) + .with_state(state); + let (_raw_csrf, form_csrf) = setup_csrf_pair(); + let body = setup_form_body(&form_csrf, "b", Some("long-enough-password")); + + for uri in ["/setup/review", "/setup/finish"] { + let response = app + .clone() + .oneshot( + Request::builder() + .method("POST") + .uri(uri) + .header(header::HOST, "localhost") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(body.clone())) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::NOT_FOUND); + } + } + + #[tokio::test] + async fn setup_get_uses_current_theme_cookie() { + install_setup_theme_test_state(); + let state = crate::test_support::app_state(); + let app = Router::new() + .route("/setup", get(setup_get)) + .with_state(state); + + let response = app + .oneshot( + Request::builder() + .uri("/setup") + .header(header::HOST, "localhost") + .header(header::COOKIE, "rustchan_theme=blue-sky") + .extension(crate::test_support::connect_info()) + .body(Body::empty()) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let body = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(body.contains(r#"data-active-theme="blue-sky""#)); + assert!(body.contains(r#"data-theme="blue-sky""#)); + } + + #[tokio::test] + async fn setup_review_uses_selected_default_theme_without_js() { + install_setup_theme_test_state(); + let state = crate::test_support::app_state(); + let app = Router::new() + .route("/setup/review", post(setup_review)) + .with_state(state); + let (_raw_csrf, form_csrf) = setup_csrf_pair(); + let body = setup_form_body(&form_csrf, "b", Some("long-enough-password")) + .replace("default_theme=forest", "default_theme=blue-sky"); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/setup/review") + .header(header::HOST, "localhost") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let body = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(body.contains(r#"data-active-theme="blue-sky""#)); + assert!(body.contains(r#"data-theme="blue-sky""#)); + assert!(body.contains(r#"name="default_theme" value="blue-sky""#)); + } + + #[tokio::test] + async fn setup_review_does_not_echo_admin_password() { + let state = crate::test_support::app_state(); + let app = Router::new() + .route("/setup/review", post(setup_review)) + .with_state(state); + let (_raw_csrf, form_csrf) = setup_csrf_pair(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/setup/review") + .header(header::HOST, "localhost") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(setup_form_body( + &form_csrf, + "b", + Some("long-enough-password"), + ))) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("body"); + let body = String::from_utf8(body.to_vec()).expect("utf8"); + assert!(!body.contains("long-enough-password")); + assert!(!body.contains("admin_password\"")); + assert!(body.contains("admin_password_token")); + } + + #[tokio::test] + async fn failed_setup_finish_does_not_mark_complete() { + let state = crate::test_support::app_state(); + { + let conn = state.db.get().expect("conn"); + crate::db::create_board(&conn, "b", "Existing", "", false).expect("board"); + } + let app = Router::new() + .route("/setup/finish", post(setup_finish)) + .with_state(state.clone()); + let (_raw_csrf, form_csrf) = setup_csrf_pair(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/setup/finish") + .header(header::HOST, "localhost") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(setup_form_body( + &form_csrf, + "b", + Some("long-enough-password"), + ))) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::CONFLICT); + let conn = state.db.get().expect("conn"); + let setup_state = db::setup_state(&conn).expect("state"); + assert!(!setup_state.completed); + } + + #[tokio::test] + async fn setup_finish_marks_completion_durably_and_persists_pdf_limit() { + let state = crate::test_support::app_state(); + let app = Router::new() + .route("/setup/finish", post(setup_finish)) + .with_state(state.clone()); + let (_raw_csrf, form_csrf) = setup_csrf_pair(); + + let response = app + .oneshot( + Request::builder() + .method("POST") + .uri("/setup/finish") + .header(header::HOST, "localhost") + .header(header::CONTENT_TYPE, "application/x-www-form-urlencoded") + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(setup_form_body( + &form_csrf, + "pdf", + Some("long-enough-password"), + ))) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::SEE_OTHER); + let conn = state.db.get().expect("conn"); + let setup_state = db::setup_state(&conn).expect("state"); + assert!(setup_state.completed); + assert!(!setup_state.reopened); + let board = db::get_board_by_short(&conn, "pdf") + .expect("load board") + .expect("board"); + assert_eq!(board.max_pdf_size, 7 * MIB_I64); + } +} diff --git a/src/handlers/thread.rs b/src/handlers/thread.rs index 80988e26..e7764fcd 100644 --- a/src/handlers/thread.rs +++ b/src/handlers/thread.rs @@ -315,6 +315,7 @@ pub async fn post_reply( access_context.board.max_image_size_bytes(), access_context.board.max_video_size_bytes(), access_context.board.max_audio_size_bytes(), + access_context.board.max_pdf_size_bytes(), ), ) .await @@ -382,17 +383,23 @@ pub async fn post_reply( .await .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; - // BadRequest → re-render the thread page with an inline error banner so the - // user sees the message in context (e.g. "wait for captcha to solve") without - // being redirected to a separate error page and losing their scroll position. + // Expected post validation errors re-render the thread page with an inline + // error banner so the user sees the message in context without being + // redirected to a separate error page and losing their scroll position. let submit_result = match result { Ok(submit_result) => submit_result, - Err(AppError::BadRequest(msg)) => { + Err(error) => { + let (status, msg) = match crate::handlers::board::handled_post_error_status(error) { + Ok(handled) => handled, + Err(error) => { + if xhr_request { + return crate::handlers::board::xhr_post_error_response(error); + } + return Err(error); + } + }; if xhr_request { - return crate::handlers::board::xhr_handled_error_response( - StatusCode::UNPROCESSABLE_ENTITY, - &msg, - ); + return crate::handlers::board::xhr_error_response(status, &msg); } let db_pool = state.db.clone(); let current_theme = crate::handlers::board::current_theme_from_jar(&jar); @@ -434,15 +441,9 @@ pub async fn post_reply( .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))??; let mut resp = axum::response::Html(html).into_response(); - *resp.status_mut() = axum::http::StatusCode::UNPROCESSABLE_ENTITY; + *resp.status_mut() = status; return Ok(resp); } - Err(error) => { - if xhr_request { - return crate::handlers::board::xhr_post_error_response(error); - } - return Err(error); - } }; let jar = crate::handlers::board::remember_owned_post_until_with_secure( @@ -1612,6 +1613,85 @@ mod tests { assert_eq!(response.status(), StatusCode::PAYLOAD_TOO_LARGE); } + #[tokio::test] + async fn post_reply_rejects_mime_mismatch_with_415_inline_error() { + let state = crate::test_support::app_state(); + let (board_id, thread_id) = { + let conn = state.db.get().expect("db connection"); + let board_id = + crate::db::create_board(&conn, "test", "Test", "", false).expect("create board"); + let post = crate::db::NewPost { + thread_id: 0, + board_id, + name: "anon".to_owned(), + tripcode: None, + subject: Some("subject".to_owned()), + body: "op".to_owned(), + body_html: "op".to_owned(), + ip_hash: None, + file_path: None, + file_name: None, + file_size: None, + thumb_path: None, + mime_type: None, + media_type: None, + audio_file_path: None, + audio_file_name: None, + audio_file_size: None, + audio_mime_type: None, + deletion_token: "token".to_owned(), + is_op: true, + }; + let (thread_id, _, _) = crate::db::create_thread_with_optional_poll( + &conn, + board_id, + Some("subject"), + &post, + "", + None, + None, + ) + .expect("create thread"); + (board_id, thread_id) + }; + assert!(board_id > 0); + + let router = Router::new() + .route("/{board}/thread/{id}", post(super::post_reply)) + .with_state(state); + let (boundary, body) = crate::test_support::multipart_body( + &[("_csrf", "csrf123"), ("body", "bad media reply")], + Some(("file", "fake.png", b"plain text", "image/png")), + ); + let response = router + .oneshot( + Request::builder() + .method("POST") + .uri(format!("/test/thread/{thread_id}")) + .header( + header::CONTENT_TYPE, + format!("multipart/form-data; boundary={boundary}"), + ) + .header(header::COOKIE, "csrf_token=csrf123") + .extension(crate::test_support::connect_info()) + .body(Body::from(body)) + .expect("request"), + ) + .await + .expect("response"); + + assert_eq!(response.status(), StatusCode::UNSUPPORTED_MEDIA_TYPE); + let body = String::from_utf8( + to_bytes(response.into_body(), usize::MAX) + .await + .expect("response body") + .to_vec(), + ) + .expect("utf8 body"); + assert!(body.contains("post-error-banner")); + assert!(body.contains("File type not allowed")); + } + fn seed_owned_post( state: &crate::middleware::AppState, allow_editing: bool, @@ -1932,7 +2012,7 @@ mod tests { .await .expect("reply response"); - assert_eq!(response.status(), StatusCode::OK); + assert_eq!(response.status(), StatusCode::UNPROCESSABLE_ENTITY); assert_eq!( response .headers() diff --git a/src/main.rs b/src/main.rs index 76f6b767..df96e970 100644 --- a/src/main.rs +++ b/src/main.rs @@ -12,6 +12,7 @@ // rustchan-cli admin ban [hours] // rustchan-cli admin unban // rustchan-cli admin list-bans +// rustchan-cli admin db-status // // Data lives in /rustchan-data/ (override with CHAN_DB / CHAN_UPLOADS) // Static CSS is compiled into the binary — no external files needed. diff --git a/src/media/convert.rs b/src/media/convert.rs index aebaa012..1f0cf76b 100644 --- a/src/media/convert.rs +++ b/src/media/convert.rs @@ -11,7 +11,7 @@ // png → WebP ONLY if the WebP output is smaller; otherwise keep PNG // svg → keep as-is (no conversion) // webp → keep as-is -// webm → keep as-is +// webm/mkv → keep as-is // all audio → keep as-is // mp4 → keep as-is (background worker handles MP4→WebM separately) // @@ -212,7 +212,13 @@ fn copy_as_is( file_stem: &str, ) -> Result { let ext = crate::utils::files::mime_to_ext_pub(mime); - copy_as_is_with_ext(input, output_dir, file_stem, ext) + copy_as_is_with_mime( + input, + output_dir, + file_stem, + ext, + upload_mime_to_static(mime), + ) } /// Copy `input` to `output_dir/{file_stem}.{ext}`, returning a `ConversionResult`. @@ -221,13 +227,21 @@ fn copy_as_is_with_ext( output_dir: &Path, file_stem: &str, ext: &str, +) -> Result { + copy_as_is_with_mime(input, output_dir, file_stem, ext, ext_to_static_mime(ext)) +} + +fn copy_as_is_with_mime( + input: &Path, + output_dir: &Path, + file_stem: &str, + ext: &str, + final_mime: &'static str, ) -> Result { let output = output_dir.join(format!("{file_stem}.{ext}")); std::fs::copy(input, &output) .with_context(|| format!("failed to copy upload to {}", output.display()))?; let final_size = file_size(&output)?; - // Determine MIME from extension for reporting - let final_mime = ext_to_static_mime(ext); Ok(ConversionResult { final_path: output, final_mime, @@ -236,6 +250,33 @@ fn copy_as_is_with_ext( }) } +fn upload_mime_to_static(mime: &str) -> &'static str { + match mime { + "image/jpeg" => "image/jpeg", + "image/png" => "image/png", + "image/gif" => "image/gif", + "image/heic" => "image/heic", + "image/heif" => "image/heif", + "image/bmp" => "image/bmp", + "image/tiff" => "image/tiff", + "image/webp" => "image/webp", + "image/svg+xml" => "image/svg+xml", + "application/pdf" => "application/pdf", + "video/webm" => "video/webm", + "video/mp4" => "video/mp4", + "video/x-matroska" | "video/matroska" => "video/x-matroska", + "audio/webm" => "audio/webm", + "audio/mpeg" | "audio/mp3" => "audio/mpeg", + "audio/ogg" | "application/ogg" | "audio/oga" => "audio/ogg", + "audio/opus" => "audio/opus", + "audio/flac" | "audio/x-flac" => "audio/flac", + "audio/wav" | "audio/wave" | "audio/x-wav" | "audio/vnd.wave" => "audio/wav", + "audio/mp4" | "audio/m4a" | "audio/x-m4a" => "audio/mp4", + "audio/aac" | "audio/x-aac" => "audio/aac", + _ => "application/octet-stream", + } +} + // ─── Path and size utilities ────────────────────────────────────────────────── /// Create a UUID-named sibling path for use as an atomic temp output. @@ -281,8 +322,10 @@ fn ext_for_original_mime(path: &Path) -> &'static str { Some("tiff" | "tif") => "tiff", Some("webp") => "webp", Some("webm") => "webm", + Some("mkv") => "mkv", Some("svg") => "svg", Some("pdf") => "pdf", + Some("opus") => "opus", _ => "bin", } } @@ -301,9 +344,11 @@ fn ext_to_static_mime(ext: &str) -> &'static str { "svg" => "image/svg+xml", "pdf" => "application/pdf", "webm" => "video/webm", + "mkv" => "video/x-matroska", "mp4" => "video/mp4", "mp3" => "audio/mpeg", "ogg" => "audio/ogg", + "opus" => "audio/opus", "flac" => "audio/flac", "wav" => "audio/wav", "m4a" => "audio/mp4", diff --git a/src/media/ffmpeg.rs b/src/media/ffmpeg.rs index 129523f9..83f5ef82 100644 --- a/src/media/ffmpeg.rs +++ b/src/media/ffmpeg.rs @@ -207,7 +207,7 @@ pub fn ffmpeg_thumbnail(input: &Path, output: &Path, max_dim: u32) -> Result<()> .with_context(|| format!("thumbnail generation failed for {in_str}")) } -/// Probe whether a `WebM` container is audio-only or contains video streams. +/// Probe whether a media container is audio-only or contains video streams. /// /// # Errors /// Returns an error if `ffprobe` cannot be spawned, times out, exits non-zero, @@ -263,12 +263,29 @@ pub fn probe_stream_kind(path: &Path) -> Result { /// Returns an error if `ffprobe` cannot be spawned, exits non-zero, or its /// output contains no recognisable codec name. pub fn probe_video_codec(path: &str) -> Result { + probe_codec(path, "v:0", "video") +} + +/// Probe the primary audio codec of a media file using `ffprobe`. +/// +/// Returns the lowercase codec name (e.g. `"flac"`, `"mp3"`, `"opus"`) on +/// success. +/// +/// # Errors +/// Returns an error if `ffprobe` cannot be spawned, exits non-zero, or its +/// output contains no recognisable codec name. +pub fn probe_audio_codec(path: &Path) -> Result { + let path_str = path_to_str(path)?; + probe_codec(path_str, "a:0", "audio") +} + +fn probe_codec(path: &str, stream_selector: &str, stream_label: &str) -> Result { let output = run_command_with_timeout( ffprobe_command().args([ "-v", "quiet", "-select_streams", - "v:0", + stream_selector, "-show_entries", "stream=codec_name", "-of", @@ -293,7 +310,7 @@ pub fn probe_video_codec(path: &str) -> Result { if codec.is_empty() { return Err(anyhow::anyhow!( - "ffprobe returned no codec name for: {path}" + "ffprobe returned no {stream_label} codec name for: {path}" )); } diff --git a/src/models.rs b/src/models.rs index b308c292..bb1c3ace 100644 --- a/src/models.rs +++ b/src/models.rs @@ -45,7 +45,7 @@ impl MediaType { match ext { "jpg" | "jpeg" | "png" | "gif" | "webp" | "heic" | "heif" | "bmp" | "tiff" | "tif" | "svg" => Self::Image, - "mp4" | "webm" => Self::Video, + "mp4" | "webm" | "mkv" => Self::Video, "mp3" | "ogg" | "flac" | "wav" | "m4a" | "aac" | "opus" => Self::Audio, "pdf" => Self::Pdf, _ => Self::Other, @@ -280,6 +280,7 @@ pub struct Board { pub max_image_size: i64, // per-board image upload size limit in bytes pub max_video_size: i64, // per-board video upload size limit in bytes pub max_audio_size: i64, // per-board audio upload size limit in bytes + pub max_pdf_size: i64, // per-board PDF upload size limit in bytes pub allow_pdf: bool, // per-board PDF upload toggle (default: off) pub allow_any_files: bool, // per-board arbitrary file upload toggle (default: off) pub allow_tripcodes: bool, @@ -324,11 +325,20 @@ impl Board { .unwrap_or(crate::config::CONFIG.max_audio_size) } + #[must_use] + pub fn max_pdf_size_bytes(&self) -> usize { + usize::try_from(self.max_pdf_size) + .ok() + .filter(|value| *value > 0) + .unwrap_or(crate::config::CONFIG.max_image_size) + } + #[must_use] pub fn max_generic_upload_size_bytes(&self) -> usize { self.max_image_size_bytes() .max(self.max_video_size_bytes()) .max(self.max_audio_size_bytes()) + .max(self.max_pdf_size_bytes()) } } diff --git a/src/pending_fs.rs b/src/pending_fs.rs index e0c42503..dfcf5b5a 100644 --- a/src/pending_fs.rs +++ b/src/pending_fs.rs @@ -1198,8 +1198,6 @@ mod tests { }; use crate::db::{init_test_pool, insert_pending_fs_op}; - static GLOBAL_ASSET_TEST_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); - struct LiveDirGuard { path: std::path::PathBuf, backup: Option, @@ -1251,6 +1249,9 @@ mod tests { } fn full_restore_payload_for_live(live: &std::path::Path) -> FullRestoreSwapPayload { + // Production startup creates this before restore validation resolves + // the allowed global favicon and banner restore targets. + std::fs::create_dir_all(crate::config::runtime_dir()).expect("create runtime dir"); FullRestoreSwapPayload { staged: generated_restore_path(live, "restore-stage") .display() @@ -1942,7 +1943,7 @@ mod tests { #[test] fn reconcile_full_restore_recovers_global_favicon_and_banner_swaps_idempotently() { - let _guard = GLOBAL_ASSET_TEST_LOCK + let _guard = crate::config::RUNTIME_LAYOUT_TEST_LOCK .lock() .unwrap_or_else(std::sync::PoisonError::into_inner); let favicon_live = crate::favicon::global_backup_source_dir(); diff --git a/src/server/cli.rs b/src/server/cli.rs index fb7cffec..8e988c0d 100644 --- a/src/server/cli.rs +++ b/src/server/cli.rs @@ -82,10 +82,21 @@ pub enum AdminAction { ban_id: i64, }, ListBans, + DbStatus, } // ─── Admin CLI mode ─────────────────────────────────────────────────────────── +fn write_db_status_output( + mut writer: W, + schema_status: &str, + sqlite_version: &str, +) -> std::io::Result<()> { + writeln!(writer, "Database schema: {schema_status}")?; + writeln!(writer, "SQLite: {sqlite_version}")?; + Ok(()) +} + #[expect(clippy::too_many_lines)] pub fn run_admin(action: AdminAction) -> anyhow::Result<()> { use crate::{db, utils::crypto}; @@ -294,13 +305,21 @@ pub fn run_admin(action: AdminAction) -> anyhow::Result<()> { } } } + AdminAction::DbStatus => { + let schema_status = db::database_schema_status_label(&conn); + write_db_status_output( + std::io::stdout().lock(), + &schema_status, + rusqlite::version(), + )?; + } } Ok(()) } #[cfg(test)] mod tests { - use super::{AdminAction, Cli, Command}; + use super::{write_db_status_output, AdminAction, Cli, Command}; use clap::Parser as _; #[test] @@ -363,4 +382,28 @@ mod tests { assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict); } + + #[test] + fn db_status_command_is_available() { + let cli = Cli::parse_from(["rustchan-cli", "admin", "db-status"]); + + let Some(Command::Admin { + action: AdminAction::DbStatus, + }) = cli.command + else { + panic!("expected db-status command"); + }; + } + + #[test] + fn db_status_output_uses_release_schema_version() { + let mut out = Vec::new(); + + write_db_status_output(&mut out, "1.3.0 baseline verified", "3.test") + .expect("write db status output"); + + let output = String::from_utf8(out).expect("utf8 output"); + assert!(output.contains("Database schema: 1.3.0 baseline verified")); + assert!(output.contains("SQLite: 3.test")); + } } diff --git a/src/server/server/observability.rs b/src/server/server/observability.rs index 0a334598..e3a784fa 100644 --- a/src/server/server/observability.rs +++ b/src/server/server/observability.rs @@ -6,8 +6,6 @@ )] use std::sync::atomic::Ordering; -use std::sync::LazyLock; -use std::time::Instant; use axum::{ extract::State, @@ -23,14 +21,9 @@ use crate::middleware::AppState; use super::{ACTIVE_IPS, ACTIVE_UPLOADS, IN_FLIGHT, REQUEST_COUNT}; -static START_TIME: LazyLock = LazyLock::new(Instant::now); - #[derive(Serialize)] struct HealthPayload { status: &'static str, - uptime_seconds: u64, - request_count: u64, - in_flight_requests: u64, } #[derive(Serialize)] @@ -39,6 +32,8 @@ struct HealthPayload { struct ReadyPayload { status: &'static str, database_ready: bool, + database_schema_version: &'static str, + database_schema_valid: bool, tor_enabled: bool, tor_onion_ready: bool, worker_queue_pending: i64, @@ -49,24 +44,61 @@ struct ReadyPayload { latest_full_backup_age_hours: Option, } +#[derive(Serialize)] +struct PublicReadyPayload { + status: &'static str, +} + pub(super) async fn healthz() -> impl IntoResponse { - Json(HealthPayload { - status: "ok", - uptime_seconds: START_TIME.elapsed().as_secs(), - request_count: REQUEST_COUNT.load(Ordering::Relaxed), - in_flight_requests: IN_FLIGHT.load(Ordering::Relaxed), - }) + Json(HealthPayload { status: "ok" }) } pub(super) async fn readyz(State(state): State) -> Response { + readyz_response(state, CONFIG.public_readiness_details).await +} + +async fn readyz_response(state: AppState, include_details: bool) -> Response { + if !include_details { + let database_ready = tokio::task::spawn_blocking({ + let pool = state.db.clone(); + move || match pool.get() { + Ok(conn) => { + let ready = conn + .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) + .ok() + .is_some_and(|value| value == 1); + ready && crate::db::verify_database_schema(&conn).is_ok() + } + Err(_) => false, + } + }) + .await + .unwrap_or(false); + + let status_label = if database_ready { "ready" } else { "degraded" }; + let status = if database_ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; + return ( + status, + Json(PublicReadyPayload { + status: status_label, + }), + ) + .into_response(); + } + let ( database_ready, + database_schema_valid, media_processing_failed, latest_full_backup_verified, latest_full_backup_age_hours, ) = tokio::task::spawn_blocking({ let pool = state.db.clone(); - move || -> (bool, i64, bool, Option) { + move || -> (bool, bool, i64, bool, Option) { let full_backups = list_backup_files(&full_backup_dir(), BackupListKind::Full); let latest_backup = full_backups.first().cloned(); let latest_full_backup_verified = @@ -82,19 +114,22 @@ pub(super) async fn readyz(State(state): State) -> Response { .query_row("SELECT 1", [], |row| row.get::<_, i64>(0)) .ok() .is_some_and(|value| value == 1); + let schema_valid = crate::db::verify_database_schema(&conn).is_ok(); let failed = crate::db::count_posts_by_media_processing_state( &conn, crate::db::MEDIA_PROCESSING_FAILED, ) .unwrap_or(0); ( - ready, + ready && schema_valid, + schema_valid, failed, latest_full_backup_verified, latest_full_backup_age_hours, ) } Err(_) => ( + false, false, 0, latest_full_backup_verified, @@ -104,16 +139,24 @@ pub(super) async fn readyz(State(state): State) -> Response { } }) .await - .unwrap_or((false, 0, false, None)); + .unwrap_or((false, false, 0, false, None)); let tor_onion_ready = if CONFIG.enable_tor_support { state.onion_address.read().await.is_some() } else { false }; + let status_label = if database_ready { "ready" } else { "degraded" }; + let status = if database_ready { + StatusCode::OK + } else { + StatusCode::SERVICE_UNAVAILABLE + }; let payload = ReadyPayload { - status: if database_ready { "ready" } else { "degraded" }, + status: status_label, database_ready, + database_schema_version: crate::db::baseline_schema_version(), + database_schema_valid, tor_enabled: CONFIG.enable_tor_support, tor_onion_ready, worker_queue_pending: state.job_queue.pending_count(), @@ -123,15 +166,17 @@ pub(super) async fn readyz(State(state): State) -> Response { latest_full_backup_verified, latest_full_backup_age_hours, }; - let status = if database_ready { - StatusCode::OK - } else { - StatusCode::SERVICE_UNAVAILABLE - }; (status, Json(payload)).into_response() } pub(super) async fn metrics(State(state): State) -> Response { + if !CONFIG.public_metrics_enabled { + return StatusCode::NOT_FOUND.into_response(); + } + metrics_response(state).await +} + +async fn metrics_response(state: AppState) -> Response { let backup = &state.backup_progress; let tor_onion_ready = if CONFIG.enable_tor_support { state.onion_address.read().await.is_some() @@ -141,12 +186,13 @@ pub(super) async fn metrics(State(state): State) -> Response { let ( media_processing_pending, media_processing_failed, + database_schema_valid, full_backup_count, latest_full_backup_verified, latest_full_backup_age_seconds, ) = tokio::task::spawn_blocking({ let pool = state.db.clone(); - move || -> (i64, i64, i64, bool, i64) { + move || -> (i64, i64, bool, i64, bool, i64) { let full_backups = list_backup_files(&full_backup_dir(), BackupListKind::Full); let full_backup_count = i64::try_from(full_backups.len()).unwrap_or(i64::MAX); let latest_full_backup_verified = @@ -157,24 +203,29 @@ pub(super) async fn metrics(State(state): State) -> Response { .map(|ts| chrono::Utc::now().timestamp().saturating_sub(ts).max(0)) .unwrap_or(-1); match pool.get() { - Ok(conn) => ( - crate::db::count_posts_by_media_processing_state( - &conn, - crate::db::MEDIA_PROCESSING_PENDING, - ) - .unwrap_or(0), - crate::db::count_posts_by_media_processing_state( - &conn, - crate::db::MEDIA_PROCESSING_FAILED, + Ok(conn) => { + let database_schema_valid = crate::db::verify_database_schema(&conn).is_ok(); + ( + crate::db::count_posts_by_media_processing_state( + &conn, + crate::db::MEDIA_PROCESSING_PENDING, + ) + .unwrap_or(0), + crate::db::count_posts_by_media_processing_state( + &conn, + crate::db::MEDIA_PROCESSING_FAILED, + ) + .unwrap_or(0), + database_schema_valid, + full_backup_count, + latest_full_backup_verified, + latest_full_backup_age_seconds, ) - .unwrap_or(0), - full_backup_count, - latest_full_backup_verified, - latest_full_backup_age_seconds, - ), + } Err(_) => ( 0, 0, + false, full_backup_count, latest_full_backup_verified, latest_full_backup_age_seconds, @@ -183,7 +234,7 @@ pub(super) async fn metrics(State(state): State) -> Response { } }) .await - .unwrap_or((0, 0, 0, false, -1)); + .unwrap_or((0, 0, false, 0, false, -1)); let body = format!( concat!( @@ -203,6 +254,8 @@ pub(super) async fn metrics(State(state): State) -> Response { "rustchan_media_processing_pending {}\n", "# TYPE rustchan_media_processing_failed gauge\n", "rustchan_media_processing_failed {}\n", + "# TYPE rustchan_database_schema_valid gauge\n", + "rustchan_database_schema_valid{{version=\"{}\"}} {}\n", "# TYPE rustchan_full_backups_saved gauge\n", "rustchan_full_backups_saved {}\n", "# TYPE rustchan_latest_full_backup_verified gauge\n", @@ -234,6 +287,8 @@ pub(super) async fn metrics(State(state): State) -> Response { state.job_queue.dropped_count(), media_processing_pending, media_processing_failed, + crate::db::baseline_schema_version(), + u8::from(database_schema_valid), full_backup_count, u8::from(latest_full_backup_verified), latest_full_backup_age_seconds, @@ -257,3 +312,75 @@ pub(super) async fn metrics(State(state): State) -> Response { ) .into_response() } + +#[cfg(test)] +mod tests { + use super::{metrics_response, readyz_response}; + use axum::{body::to_bytes, http::StatusCode}; + + #[tokio::test] + async fn public_readyz_response_hides_operational_details() { + let response = readyz_response(crate::test_support::app_state(), false).await; + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("ready body"); + let body: serde_json::Value = serde_json::from_slice(&body).expect("ready json"); + + assert_eq!( + body.get("status").and_then(serde_json::Value::as_str), + Some("ready") + ); + assert!(body.get("database_schema_version").is_none()); + assert!(body.get("database_schema_valid").is_none()); + assert!(body.get("worker_queue_pending").is_none()); + assert!(body.get("media_processing_failed").is_none()); + assert!(body.get("latest_full_backup_verified").is_none()); + assert!(body.get("tor_enabled").is_none()); + } + + #[tokio::test] + async fn detailed_readyz_response_remains_available_when_enabled() { + let response = readyz_response(crate::test_support::app_state(), true).await; + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("ready body"); + let body: serde_json::Value = serde_json::from_slice(&body).expect("ready json"); + + assert_eq!( + body.get("status").and_then(serde_json::Value::as_str), + Some("ready") + ); + assert_eq!( + body.get("database_schema_version") + .and_then(serde_json::Value::as_str), + Some(crate::db::baseline_schema_version()) + ); + assert_eq!( + body.get("database_schema_valid") + .and_then(serde_json::Value::as_bool), + Some(true) + ); + assert!(body.get("worker_queue_pending").is_some()); + assert!(body.get("latest_full_backup_verified").is_some()); + assert!(body.get("tor_enabled").is_some()); + } + + #[tokio::test] + async fn metrics_response_remains_available_for_enabled_scrapers() { + let response = metrics_response(crate::test_support::app_state()).await; + + assert_eq!(response.status(), StatusCode::OK); + let body = to_bytes(response.into_body(), usize::MAX) + .await + .expect("metrics body"); + let body = String::from_utf8(body.to_vec()).expect("utf8 metrics"); + + assert!(body.contains("rustchan_requests_total")); + assert!(body.contains("rustchan_job_queue_pending")); + assert!(body.contains("rustchan_database_schema_valid{version=\"1.3.0\"} 1")); + } +} diff --git a/src/server/server/router.rs b/src/server/server/router.rs index 1616f633..cd2b0fcf 100644 --- a/src/server/server/router.rs +++ b/src/server/server/router.rs @@ -233,7 +233,7 @@ mod tests { } #[tokio::test] - async fn health_endpoints_emit_request_id_and_metrics() { + async fn public_health_endpoints_emit_request_id_without_observability_details() { let router = build_router(crate::test_support::app_state(), false); let health = router @@ -248,6 +248,19 @@ mod tests { .expect("health response"); assert_eq!(health.status(), StatusCode::OK); assert!(health.headers().contains_key("x-request-id")); + let health_body = to_bytes(health.into_body(), usize::MAX) + .await + .expect("health body"); + let health_body: serde_json::Value = + serde_json::from_slice(&health_body).expect("health json"); + assert_eq!( + health_body + .get("status") + .and_then(serde_json::Value::as_str), + Some("ok") + ); + assert!(health_body.get("request_count").is_none()); + assert!(health_body.get("uptime_seconds").is_none()); let ready = router .clone() @@ -260,6 +273,18 @@ mod tests { .await .expect("ready response"); assert_eq!(ready.status(), StatusCode::OK); + let ready_body = to_bytes(ready.into_body(), usize::MAX) + .await + .expect("ready body"); + let ready_body: serde_json::Value = + serde_json::from_slice(&ready_body).expect("ready json"); + assert_eq!( + ready_body.get("status").and_then(serde_json::Value::as_str), + Some("ready") + ); + assert!(ready_body.get("database_schema_version").is_none()); + assert!(ready_body.get("tor_enabled").is_none()); + assert!(ready_body.get("latest_full_backup_age_hours").is_none()); let metrics = router .oneshot( @@ -270,13 +295,7 @@ mod tests { ) .await .expect("metrics response"); - assert_eq!(metrics.status(), StatusCode::OK); - let body = to_bytes(metrics.into_body(), usize::MAX) - .await - .expect("metrics body"); - let body = String::from_utf8(body.to_vec()).expect("utf8 metrics"); - assert!(body.contains("rustchan_requests_total")); - assert!(body.contains("rustchan_job_queue_pending")); + assert_eq!(metrics.status(), StatusCode::NOT_FOUND); } #[tokio::test] diff --git a/src/server/server/routes.rs b/src/server/server/routes.rs index 135035f3..b6919abd 100644 --- a/src/server/server/routes.rs +++ b/src/server/server/routes.rs @@ -69,6 +69,9 @@ pub(super) fn public_routes() -> Router { "/banner/external/{id}/continue", get(crate::handlers::banner::external_banner_continue), ) + .route("/setup", get(crate::handlers::setup::setup_get)) + .route("/setup/review", post(crate::handlers::setup::setup_review)) + .route("/setup/finish", post(crate::handlers::setup::setup_finish)) .route("/", get(crate::handlers::board::index)) .route("/{board}", get(crate::handlers::board::board_index)) .route( @@ -170,6 +173,10 @@ fn admin_auth_routes() -> Router { "/admin/site-health/jobs", get(crate::handlers::admin::admin_site_health_jobs), ) + .route( + "/admin/site-health/jobs/dismiss", + post(crate::handlers::admin::dismiss_failed_site_health_jobs), + ) .route( "/admin/log/live", get(crate::handlers::admin::admin_live_log), @@ -297,6 +304,14 @@ fn admin_moderation_routes() -> Router { "/admin/media/settings", post(crate::handlers::admin::update_media_settings), ) + .route( + "/admin/setup/reopen", + post(crate::handlers::setup::admin_reopen_setup), + ) + .route( + "/admin/setup/close", + post(crate::handlers::setup::admin_close_setup), + ) .route( "/admin/db/repair", get(crate::handlers::admin::admin_db_repair_status) diff --git a/src/templates/admin.rs b/src/templates/admin.rs index e0f2e349..dbbf4755 100644 --- a/src/templates/admin.rs +++ b/src/templates/admin.rs @@ -83,6 +83,7 @@ pub struct AdminPanelViewModel<'a> { pub csrf_token: &'a str, pub boards: &'a [Board], pub current_theme: Option<&'a str>, + pub dashboard: AdminPanelDashboardView<'a>, pub moderation: AdminPanelModerationView<'a>, pub appearance: AdminPanelAppearanceView<'a>, pub site_health: AdminPanelSiteHealthView<'a>, @@ -93,6 +94,51 @@ pub struct AdminPanelViewModel<'a> { pub open_section: Option<&'a str>, } +pub struct AdminPanelDashboardView<'a> { + pub version: &'a str, + pub build: &'a str, + pub setup_status: &'a str, + pub setup_detail: &'a str, + pub setup_state: AdminDashboardState, + pub site_title: &'a str, + pub public_url: &'a str, + pub db_status: &'a str, + pub db_detail: &'a str, + pub db_state: AdminDashboardState, + pub backup_status: &'a str, + pub backup_detail: &'a str, + pub backup_state: AdminDashboardState, + pub storage_status: &'a str, + pub storage_detail: &'a str, + pub storage_state: AdminDashboardState, + pub tor_status: &'a str, + pub tor_detail: &'a str, + pub tor_state: AdminDashboardState, + pub dependency_status: &'a str, + pub dependency_detail: &'a str, + pub dependency_state: AdminDashboardState, + pub job_status: &'a str, + pub job_detail: &'a str, + pub job_state: AdminDashboardState, + pub board_count: &'a str, + pub thread_count: &'a str, + pub post_count: &'a str, + pub recent_activity: &'a str, + pub media_summary: &'a str, + pub report_status: &'a str, + pub report_detail: &'a str, + pub report_state: AdminDashboardState, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum AdminDashboardState { + Ok, + Warning, + ActionNeeded, + Disabled, + Unknown, +} + pub struct AdminPanelModerationView<'a> { pub bans: &'a [Ban], pub filters: &'a [WordFilter], @@ -132,6 +178,7 @@ pub struct AdminPanelBackupsView<'a> { pub struct AdminPanelSiteHealthView<'a> { pub server_status: &'a str, pub rustchan_version: &'a str, + pub database_schema_status: &'a str, pub database_integrity_status: &'a str, pub last_successful_backup: &'a str, pub next_scheduled_backup: &'a str, @@ -139,6 +186,10 @@ pub struct AdminPanelSiteHealthView<'a> { pub upload_dir_size: &'a str, pub tor_status: &'a str, pub tor_onion_address: Option<&'a str>, + pub tor_service_status: &'a str, + pub tor_mode: &'a str, + pub tor_config_summary: &'a str, + pub tor_detail: &'a str, pub dependency_summary: AdminSiteHealthDependencySummary, pub running_jobs: i64, pub queued_jobs: i64, @@ -161,12 +212,21 @@ pub struct AdminSiteHealthDependencySummary { pub struct AdminPanelMaintenanceView { pub db_size_bytes: i64, pub db_size_warning: bool, + pub setup_status: AdminPanelSetupStatus, pub ffmpeg_timeout_secs: u64, pub media_auto_prune_enabled: bool, pub media_max_active_content_size_bytes: u64, pub media_detection: AdminMediaDetectionView, } +#[derive(Clone, Copy, Eq, PartialEq)] +pub enum AdminPanelSetupStatus { + Available, + Complete, + Reopened, + Initialized, +} + #[derive(Clone, Copy, Eq, PartialEq)] pub enum AdminDetectionStatus { Detected, @@ -670,8 +730,11 @@ fn render_board_settings_card( + -

PDF and any-file uploads still use the largest enabled cap for this board.

+

PDF uploads use the PDF cap. Any-file uploads use the largest configured cap for this board.

@@ -792,6 +855,7 @@ fn render_board_settings_card( bytes_to_mib(board.max_video_size, crate::config::CONFIG.max_video_size), max_audio_size_mb = bytes_to_mib(board.max_audio_size, crate::config::CONFIG.max_audio_size), + max_pdf_size_mb = bytes_to_mib(board.max_pdf_size, crate::config::CONFIG.max_image_size), pdf_checked = checked(board.allow_pdf), tripcodes_checked = checked(board.allow_tripcodes), video_embeds_checked = checked(board.allow_video_embeds), @@ -1419,7 +1483,8 @@ pub fn admin_db_repair_failed_page( fn render_db_health_snapshot(snapshot: &crate::db::DbHealthSnapshot) -> String { format!( - "{integrity}{foreign_keys}", + "{schema}{integrity}{foreign_keys}", + schema = render_db_check_result("schema baseline", &snapshot.schema), integrity = render_db_check_result("integrity check", &snapshot.integrity), foreign_keys = render_db_check_result("foreign key check", &snapshot.foreign_keys), ) @@ -1698,9 +1763,10 @@ pub fn admin_ip_history_page( mod tests { use super::{ admin_db_health_result_page, admin_db_repair_idle_page, admin_login_page, admin_panel_page, - render_board_appearance_card, render_board_settings_card, AdminDetectionStatus, - AdminMediaDetectionView, AdminPanelAppearanceView, AdminPanelBackupsView, - AdminPanelMaintenanceView, AdminPanelModerationView, AdminPanelSiteHealthView, + render_board_appearance_card, render_board_settings_card, AdminDashboardState, + AdminDetectionStatus, AdminMediaDetectionView, AdminPanelAppearanceView, + AdminPanelBackupsView, AdminPanelDashboardView, AdminPanelMaintenanceView, + AdminPanelModerationView, AdminPanelSetupStatus, AdminPanelSiteHealthView, AdminPanelViewModel, AdminSiteHealthDependencySummary, }; use crate::db::{DbCheckResult, DbHealthReport, DbHealthSnapshot}; @@ -1727,6 +1793,7 @@ mod tests { max_image_size: 8 * 1024 * 1024, max_video_size: 50 * 1024 * 1024, max_audio_size: 150 * 1024 * 1024, + max_pdf_size: 8 * 1024 * 1024, allow_pdf: false, allow_any_files: false, allow_tripcodes: true, @@ -1893,14 +1960,19 @@ mod tests { fn sample_site_health() -> AdminPanelSiteHealthView<'static> { AdminPanelSiteHealthView { server_status: "ready", - rustchan_version: "1.2.2", + rustchan_version: "1.3.0", + database_schema_status: "1.3.0 baseline verified", database_integrity_status: "not checked", last_successful_backup: "none saved", next_scheduled_backup: "not scheduled", data_dir_usage: "unknown", upload_dir_size: "unknown", - tor_status: "disabled", + tor_status: "disabled; set enable_tor_support = true in settings.toml, then restart", tor_onion_address: None, + tor_service_status: "not started; enable Tor support in settings.toml and restart", + tor_mode: "clearnet only", + tor_config_summary: "bootstrap timeout 30s; max streams 64", + tor_detail: "Set enable_tor_support = true in settings.toml, then restart RustChan.", dependency_summary: AdminSiteHealthDependencySummary { ffmpeg: AdminDetectionStatus::Detected, ffprobe: AdminDetectionStatus::Detected, @@ -1914,7 +1986,46 @@ mod tests { failed_jobs: 0, backup_jobs: "idle", restore_jobs: "not available", - diagnostics_text: "RustChan version: 1.2.2\nRecent warnings:\n none", + diagnostics_text: + "RustChan version: 1.3.0\nDatabase schema: 1.3.0 baseline verified\nRecent warnings:\n none", + } + } + + fn sample_dashboard() -> AdminPanelDashboardView<'static> { + AdminPanelDashboardView { + version: "1.3.0", + build: "test/test", + setup_status: "complete", + setup_detail: "Public setup routes are blocked.", + setup_state: AdminDashboardState::Ok, + site_title: "RustChan", + public_url: "not configured", + db_status: "ready", + db_detail: "Integrity: not checked.", + db_state: AdminDashboardState::Unknown, + backup_status: "current", + backup_detail: "All saved backups verified.", + backup_state: AdminDashboardState::Ok, + storage_status: "uploads unknown", + storage_detail: "Data directory unknown; active media unknown.", + storage_state: AdminDashboardState::Unknown, + tor_status: "disabled", + tor_detail: "Set enable_tor_support = true in settings.toml, then restart RustChan.", + tor_state: AdminDashboardState::Disabled, + dependency_status: "ready", + dependency_detail: "ffmpeg found; ffprobe found; WebP found; VP9 found; Opus found.", + dependency_state: AdminDashboardState::Ok, + job_status: "idle", + job_detail: "Recently completed 0; backup job idle; restore jobs not available.", + job_state: AdminDashboardState::Ok, + board_count: "1 board", + thread_count: "0 active / 0 total", + post_count: "0 posts", + recent_activity: "0 posts in 24h; 0 in 7d", + media_summary: "0 upload posts; 0 images, 0 video, 0 audio; 0 B active", + report_status: "no open reports", + report_detail: "0 reports in 7d; 0 open appeals.", + report_state: AdminDashboardState::Ok, } } @@ -1950,6 +2061,7 @@ mod tests { csrf_token: "csrf", boards, current_theme: None, + dashboard: sample_dashboard(), moderation: AdminPanelModerationView { bans: &[], filters: &[], @@ -1986,6 +2098,7 @@ mod tests { maintenance: AdminPanelMaintenanceView { db_size_bytes: 4096, db_size_warning: false, + setup_status: AdminPanelSetupStatus::Complete, ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS, media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, @@ -2129,6 +2242,7 @@ mod tests { max_image_size: 25 * 1024 * 1024, max_video_size: 500 * 1024 * 1024, max_audio_size: 300 * 1024 * 1024, + max_pdf_size: 12 * 1024 * 1024, ..sample_board() }; let html = render_board_settings_card( @@ -2144,14 +2258,16 @@ mod tests { assert!(html.contains(r#"name="max_image_size_mb""#)); assert!(html.contains(r#"name="max_video_size_mb""#)); assert!(html.contains(r#"name="max_audio_size_mb""#)); + assert!(html.contains(r#"name="max_pdf_size_mb""#)); assert!(html.contains(r#"value="25""#)); assert!(html.contains(r#"value="500""#)); assert!(html.contains(r#"value="300""#)); + assert!(html.contains(r#"value="12""#)); assert!(!html.contains("Cannot exceed the site-wide")); assert!(!html.contains(r#"max="8""#)); assert!(!html.contains(r#"max="50""#)); assert!(!html.contains(r#"max="150""#)); - assert!(html.contains("PDF and any-file uploads still use the largest enabled cap")); + assert!(html.contains("PDF uploads use the PDF cap")); } #[test] @@ -2246,14 +2362,23 @@ mod tests { assert!(html.contains("Database integrity status")); assert!(html.contains("open media panel")); assert!(html.contains("copy diagnostics")); - assert!(html.contains("RustChan version: 1.2.2")); + assert!(html.contains("RustChan version: 1.3.0")); + assert!(html.contains("Database schema")); + assert!(html.contains("1.3.0 baseline verified")); assert!(html.contains(r#"data-admin-health-jobs-url="/admin/site-health/jobs""#)); assert!(html.contains(r#"data-admin-health-job="running_jobs""#)); assert!(html.contains(r#"data-admin-health-job="queued_jobs""#)); assert!(html.contains(r#"data-admin-health-toggle="failed""#)); assert!(html.contains(r#"data-admin-health-job-list="failed""#)); + assert!(html.contains(r#"action="/admin/site-health/jobs/dismiss""#)); + assert!(html.contains(r#"name="_csrf" value="csrf""#)); + assert!(html.contains("dismiss counter")); assert!(html.contains(r"data-admin-health-close")); - assert!(!html.contains("Tor bootstrap state")); + assert!(html.contains(r#"id="tor-status""#)); + assert!(html.contains("// Tor diagnostics")); + assert!(html.contains("Onion service")); + assert!(html.contains("Runtime config")); + assert!(html.contains("Set enable_tor_support = true in settings.toml")); assert!(!html.contains("Thumbnail/transcode jobs")); assert!(!html.contains("Repair/VACUUM jobs")); } @@ -2274,6 +2399,41 @@ mod tests { )); } + #[test] + fn admin_panel_control_center_uses_persistent_dropdown_pattern() { + let board = sample_board(); + let themes = vec![sample_theme()]; + let html = render_admin_panel_for_test(std::slice::from_ref(&board), &[], &themes, None); + + assert!(html.contains( + r#"
"# + )); + assert!(html.contains(r##"href="#public-url-settings""##)); + assert!(html.contains(r##"href="#tor-status""##)); + assert!(html.contains(r#"id="public-url-settings""#)); + assert!(html.contains("settings.toml public_hosts")); + } + + #[test] + fn admin_panel_control_center_honors_open_target() { + let board = sample_board(); + let themes = vec![sample_theme()]; + let html = render_admin_panel_for_test( + std::slice::from_ref(&board), + &[], + &themes, + Some("control-center"), + ); + + assert!(html.contains( + r#"
"# + )); + } + #[test] fn board_appearance_card_keeps_nsfw_tag() { let mut board = sample_board(); @@ -2353,6 +2513,10 @@ mod tests { fn admin_db_result_pages_use_shared_status_surfaces() { let report = DbHealthReport { before: DbHealthSnapshot { + schema: DbCheckResult { + ok: true, + messages: vec!["1.3.0 baseline verified".into()], + }, integrity: DbCheckResult { ok: false, messages: vec!["row 1".into(), "row 2".into()], @@ -2586,6 +2750,7 @@ mod tests { csrf_token: "csrf", boards: std::slice::from_ref(&board), current_theme: Some("blue-sky"), + dashboard: sample_dashboard(), moderation: AdminPanelModerationView { bans: &[], filters: &[], @@ -2622,6 +2787,7 @@ mod tests { maintenance: AdminPanelMaintenanceView { db_size_bytes: 4096, db_size_warning: false, + setup_status: AdminPanelSetupStatus::Complete, ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS, media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, @@ -2675,6 +2841,7 @@ mod tests { csrf_token: "csrf", boards: std::slice::from_ref(&board), current_theme: Some("blue-sky"), + dashboard: sample_dashboard(), moderation: AdminPanelModerationView { bans: &[], filters: &[], @@ -2711,6 +2878,7 @@ mod tests { maintenance: AdminPanelMaintenanceView { db_size_bytes: 0, db_size_warning: false, + setup_status: AdminPanelSetupStatus::Complete, ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS, media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, @@ -2755,6 +2923,7 @@ mod tests { csrf_token: "csrf", boards: std::slice::from_ref(&board), current_theme: Some("blue-sky"), + dashboard: sample_dashboard(), moderation: AdminPanelModerationView { bans: &[], filters: &[], @@ -2791,6 +2960,7 @@ mod tests { maintenance: AdminPanelMaintenanceView { db_size_bytes: 0, db_size_warning: false, + setup_status: AdminPanelSetupStatus::Complete, ffmpeg_timeout_secs: crate::config::DEFAULT_FFMPEG_TIMEOUT_SECS, media_auto_prune_enabled: false, media_max_active_content_size_bytes: 0, diff --git a/src/templates/admin/appearance.rs b/src/templates/admin/appearance.rs index 0a3d100c..996ddb51 100644 --- a/src/templates/admin/appearance.rs +++ b/src/templates/admin/appearance.rs @@ -35,6 +35,11 @@ pub(super) fn render_site_settings(view: &AdminPanelViewModel<'_>) -> String { } else { "No custom global favicon uploaded yet." }; + let public_url_help = if view.dashboard.public_url == "not configured" { + "No public URL is configured. Add at least one hostname to settings.toml public_hosts, then restart RustChan." + } else { + "Runtime host trust uses settings.toml public_hosts. To change this URL, edit public_hosts and restart RustChan." + }; render_admin_site_settings_section( view.csrf_token, @@ -44,6 +49,8 @@ pub(super) fn render_site_settings(view: &AdminPanelViewModel<'_>) -> String { view.appearance.homepage_new_reply_badges_enabled, view.appearance.thread_new_reply_badges_enabled, &render_enabled_theme_options(view), + view.dashboard.public_url, + public_url_help, &global_favicon_preview, global_favicon_label, global_favicon_button, @@ -708,6 +715,17 @@ fn render_theme_cards(view: &AdminPanelViewModel<'_>) -> (String, String) { (builtin_theme_cards, custom_theme_cards) } +fn render_public_url_copy_button(public_url: &str) -> String { + if public_url == "not configured" { + String::new() + } else { + format!( + r#""#, + public_url = escape_html(public_url), + ) + } +} + // The signature mirrors the data passed between layers, so a wrapper would add more noise than clarity. #[expect(clippy::too_many_arguments)] fn render_admin_site_settings_section( @@ -718,11 +736,14 @@ fn render_admin_site_settings_section( homepage_new_reply_badges_enabled: bool, thread_new_reply_badges_enabled: bool, enabled_theme_options: &str, + public_url: &str, + public_url_help: &str, global_favicon_preview: &str, global_favicon_label: &str, global_favicon_button: &str, global_favicon_status: &str, ) -> String { + let public_url_copy_button = render_public_url_copy_button(public_url); format!( r#"
-
-
"# - .to_owned() +"#, + ) +} + +fn render_admin_dashboard_section(view: &AdminPanelViewModel<'_>) -> String { + let dashboard = &view.dashboard; + let overview_cards = render_dashboard_overview_cards(view); + let health_cards = render_dashboard_health_cards(view); + let activity_cards = render_dashboard_activity_cards(view); + let quick_actions = render_dashboard_quick_actions(view); + let open_attr = if view.open_section == Some("control-center") { + " open" + } else { + "" + }; + + format!( + r#" +
+
+// control center{overall_status} +
+
+
+

Operational summary for {site_title}.

+
+
+ {overall_status} +
+
+
+
+

// instance overview

+

Version, setup state, configured public entry point, and safe navigation.

+
+
{overview_cards}
+
+
+
+

// health and needs attention

+

Cheap startup and stored status checks only.

+
+
{health_cards}
+
+
+
+

// activity and moderation

+

Current boards, posting activity, media totals, and report queues.

+
+
{activity_cards}
+
+
+
+

// quick actions

+

Links into existing admin tools; mutating actions keep their normal protections.

+
+ {quick_actions} +
+
+
+
"#, + site_title = escape_html(dashboard.site_title), + overall_status = render_dashboard_overall_status(dashboard), + open_attr = open_attr, + ) +} + +fn render_dashboard_overall_status(dashboard: &super::AdminPanelDashboardView<'_>) -> String { + let state = [ + dashboard.setup_state, + dashboard.db_state, + dashboard.backup_state, + dashboard.storage_state, + dashboard.tor_state, + dashboard.dependency_state, + dashboard.job_state, + dashboard.report_state, + ] + .into_iter() + .max_by_key(|state| state_severity(*state)) + .unwrap_or(AdminDashboardState::Unknown); + let label = match state { + AdminDashboardState::Ok => "OK", + AdminDashboardState::Warning => "Warning", + AdminDashboardState::ActionNeeded => "Action needed", + AdminDashboardState::Disabled => "Disabled", + AdminDashboardState::Unknown => "Unknown", + }; + render_state_pill(state, label) +} + +const fn state_severity(state: AdminDashboardState) -> u8 { + match state { + AdminDashboardState::ActionNeeded => 4, + AdminDashboardState::Warning => 3, + AdminDashboardState::Unknown => 2, + AdminDashboardState::Disabled => 1, + AdminDashboardState::Ok => 0, + } +} + +fn render_dashboard_overview_cards(view: &AdminPanelViewModel<'_>) -> String { + let dashboard = &view.dashboard; + let mut out = String::new(); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Version and build", + value: dashboard.version, + detail: dashboard.build, + state: AdminDashboardState::Ok, + href: Some("#site-health"), + action: Some("site health"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Setup status", + value: dashboard.setup_status, + detail: dashboard.setup_detail, + state: dashboard.setup_state, + href: Some("#database-maintenance"), + action: Some("setup controls"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Site title", + value: dashboard.site_title, + detail: "Rendered from saved site settings.", + state: AdminDashboardState::Ok, + href: Some("#site-settings"), + action: Some("site settings"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Public URL", + value: dashboard.public_url, + detail: if dashboard.public_url == "not configured" { + "Add a public_hosts entry in settings.toml, then restart RustChan." + } else { + "To change this, edit settings.toml public_hosts and restart RustChan." + }, + state: if dashboard.public_url == "not configured" { + AdminDashboardState::Unknown + } else { + AdminDashboardState::Ok + }, + href: Some("#public-url-settings"), + action: Some("public URL"), + }, + ); + out +} + +fn render_dashboard_health_cards(view: &AdminPanelViewModel<'_>) -> String { + let dashboard = &view.dashboard; + let mut out = String::new(); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Database", + value: dashboard.db_status, + detail: dashboard.db_detail, + state: dashboard.db_state, + href: Some("#database-maintenance"), + action: Some("maintenance"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Backups", + value: dashboard.backup_status, + detail: dashboard.backup_detail, + state: dashboard.backup_state, + href: Some("#full-backup-restore"), + action: Some("backups"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Storage", + value: dashboard.storage_status, + detail: dashboard.storage_detail, + state: dashboard.storage_state, + href: Some("#media-settings"), + action: Some("media settings"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Tor", + value: dashboard.tor_status, + detail: dashboard.tor_detail, + state: dashboard.tor_state, + href: Some("#tor-status"), + action: Some("tor status"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Media tools", + value: dashboard.dependency_status, + detail: dashboard.dependency_detail, + state: dashboard.dependency_state, + href: Some("#media-settings"), + action: Some("dependencies"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Jobs", + value: dashboard.job_status, + detail: dashboard.job_detail, + state: dashboard.job_state, + href: Some("#site-health"), + action: Some("job details"), + }, + ); + out +} + +fn render_dashboard_activity_cards(view: &AdminPanelViewModel<'_>) -> String { + let dashboard = &view.dashboard; + let mut out = String::new(); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Boards", + value: dashboard.board_count, + detail: "Configured board directory.", + state: AdminDashboardState::Ok, + href: Some("#boards"), + action: Some("manage boards"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Threads", + value: dashboard.thread_count, + detail: "Live and total thread counts.", + state: AdminDashboardState::Ok, + href: Some("#boards"), + action: Some("board tools"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Posts", + value: dashboard.post_count, + detail: dashboard.recent_activity, + state: AdminDashboardState::Ok, + href: Some("/"), + action: Some("home"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Uploads", + value: dashboard.media_summary, + detail: "Active media bytes are counted from stored post metadata.", + state: AdminDashboardState::Ok, + href: Some("#media-settings"), + action: Some("media"), + }, + ); + append_dashboard_metric( + &mut out, + DashboardMetric { + label: "Reports", + value: dashboard.report_status, + detail: dashboard.report_detail, + state: dashboard.report_state, + href: Some("#reports"), + action: Some("moderation"), + }, + ); + out +} + +fn render_dashboard_quick_actions(view: &AdminPanelViewModel<'_>) -> String { + let close_setup = if view.dashboard.setup_state == AdminDashboardState::Warning + && view.dashboard.setup_status == "reopened" + { + format!( + r#"
+ + +
"#, + csrf = escape_html(view.csrf_token), + ) + } else { + String::new() + }; + + format!( + r##"
+ manage boards + create board + backups + health + dependencies + logs + mod log +
+ setup controls +
+ + +
+ {close_setup} +
+
+ diagnostics +

Use the Site Health diagnostics panel for copy support. Without JavaScript, the diagnostics text remains selectable there.

+ open diagnostics +
+
"##, + csrf = escape_html(view.csrf_token), + close_setup = close_setup, + ) +} + +#[derive(Clone, Copy)] +struct DashboardMetric<'a> { + label: &'a str, + value: &'a str, + detail: &'a str, + state: AdminDashboardState, + href: Option<&'a str>, + action: Option<&'a str>, +} + +fn append_dashboard_metric(out: &mut String, metric: DashboardMetric<'_>) { + let action = match (metric.href, metric.action) { + (Some(href), Some(action)) => format!( + r#"{action}"#, + href = escape_html(href), + action = escape_html(action), + ), + _ => String::new(), + }; + let _ = write!( + out, + r#"
+
+ {label} + {pill} +
+ {value} +

{detail}

+ {action} +
"#, + state_class = state_class(metric.state), + label = escape_html(metric.label), + pill = render_state_pill(metric.state, state_label(metric.state)), + value = escape_html(metric.value), + detail = escape_html(metric.detail), + action = action, + ); +} + +fn render_state_pill(state: AdminDashboardState, label: &str) -> String { + format!( + r#"{label}"#, + class = state_class(state), + label = escape_html(label), + ) +} + +const fn state_class(state: AdminDashboardState) -> &'static str { + match state { + AdminDashboardState::Ok => "ok", + AdminDashboardState::Warning => "warning", + AdminDashboardState::ActionNeeded => "action-needed", + AdminDashboardState::Disabled => "disabled", + AdminDashboardState::Unknown => "unknown", + } +} + +const fn state_label(state: AdminDashboardState) -> &'static str { + match state { + AdminDashboardState::Ok => "OK", + AdminDashboardState::Warning => "warning", + AdminDashboardState::ActionNeeded => "action", + AdminDashboardState::Disabled => "disabled", + AdminDashboardState::Unknown => "unknown", + } } diff --git a/src/templates/admin/maintenance.rs b/src/templates/admin/maintenance.rs index 3583192d..2a426377 100644 --- a/src/templates/admin/maintenance.rs +++ b/src/templates/admin/maintenance.rs @@ -14,6 +14,10 @@ struct MaintenanceSectionView<'a> { media_detection_cards: &'a str, media_settings_open_attr: &'a str, database_maintenance_open_attr: &'a str, + setup_status: &'a str, + setup_status_detail: &'a str, + setup_reopen_warning: &'a str, + setup_close_control: &'a str, } pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { @@ -59,6 +63,37 @@ old boards to prevent query performance degradation. let (media_max_value, media_max_unit) = media_size_input_parts(view.maintenance.media_max_active_content_size_bytes); let media_detection_cards = render_media_detection_cards(view); + let (setup_status, setup_status_detail) = match view.maintenance.setup_status { + super::AdminPanelSetupStatus::Reopened => ( + "reopened", + "The setup wizard has been reopened by an admin and is available only to authenticated admins.", + ), + super::AdminPanelSetupStatus::Complete => ( + "complete", + "The setup wizard has completed and public setup routes are blocked.", + ), + super::AdminPanelSetupStatus::Available => ( + "available", + "This instance still appears to be in first-run setup.", + ), + super::AdminPanelSetupStatus::Initialized => ( + "initialized", + "This instance has existing durable runtime state, so first-run setup routes are blocked.", + ), + }; + let setup_reopen_warning = "Reopening setup exposes live settings for editing. It does not replace existing admin credentials and still requires an authenticated admin session."; + let setup_close_control = if matches!( + view.maintenance.setup_status, + super::AdminPanelSetupStatus::Reopened + ) { + r#"
+ + +
"# + } else { + "" + }; let section_view = MaintenanceSectionView { csrf_token: view.csrf_token, db_warn_banner: &db_warn_banner, @@ -72,6 +107,10 @@ old boards to prevent query performance degradation. media_detection_cards: &media_detection_cards, media_settings_open_attr, database_maintenance_open_attr, + setup_status, + setup_status_detail, + setup_reopen_warning, + setup_close_control, }; render_admin_maintenance_section(§ion_view) } @@ -260,6 +299,22 @@ fn render_admin_maintenance_section(view: &MaintenanceSectionView<'_>) -> String data-confirm="Run VACUUM? This will briefly block the database while it rebuilds. Continue?">🧹 run VACUUM +
+
+

// setup status

+

First-run setup route state and controlled maintenance reopen.

+
+

+ Setup status: {setup_status}. {setup_status_detail} +

+

{setup_reopen_warning}

+
+ + +
+ {setup_close_control} +
@@ -300,6 +355,12 @@ fn render_admin_maintenance_section(view: &MaintenanceSectionView<'_>) -> String ffmpeg_timeout_max = crate::config::MAX_FFMPEG_TIMEOUT_SECS, media_settings_open_attr = view.media_settings_open_attr, database_maintenance_open_attr = view.database_maintenance_open_attr, + setup_status = escape_html(view.setup_status), + setup_status_detail = escape_html(view.setup_status_detail), + setup_reopen_warning = escape_html(view.setup_reopen_warning), + setup_close_control = view + .setup_close_control + .replace("{csrf}", &escape_html(view.csrf_token)), tor_section = view.tor_section, ) } diff --git a/src/templates/admin/site_health.rs b/src/templates/admin/site_health.rs index afefff0c..d9f78ab0 100644 --- a/src/templates/admin/site_health.rs +++ b/src/templates/admin/site_health.rs @@ -10,6 +10,7 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String { let health = &view.site_health; let rows = render_health_rows(view); let dependency_rows = render_dependency_summary(view); + let tor_rows = render_tor_diagnostics(view); let recent_jobs = render_recent_jobs_panel(); let diagnostics = escape_html(health.diagnostics_text); format!( @@ -22,6 +23,13 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String {
{rows}
{recent_jobs} +
+
+

// Tor diagnostics

+

Runtime onion-service state and safe configuration signals.

+
+
{tor_rows}
+

// optional dependency summary

@@ -38,7 +46,7 @@ pub(super) fn render(view: &AdminPanelViewModel<'_>) -> String {

// diagnostics

- +
@@ -57,6 +65,7 @@ fn render_health_rows(view: &AdminPanelViewModel<'_>) -> String { for (label, value) in [ ("Server status", health.server_status), ("RustChan version", health.rustchan_version), + ("Database schema", health.database_schema_status), ( "Database integrity status", health.database_integrity_status, @@ -65,11 +74,6 @@ fn render_health_rows(view: &AdminPanelViewModel<'_>) -> String { ("Next scheduled backup", health.next_scheduled_backup), ("Disk usage for rustchan-data/", health.data_dir_usage), ("Upload directory size", health.upload_dir_size), - ("Tor status", health.tor_status), - ( - "Tor onion address", - health.tor_onion_address.unwrap_or("not available"), - ), ] { append_health_row(&mut rows, label, value); } @@ -84,27 +88,43 @@ fn append_job_rows(rows: &mut String, view: &AdminPanelViewModel<'_>) { "Running jobs", &health.running_jobs.to_string(), "running_jobs", + view.csrf_token, ); append_health_job_row( rows, "Queued jobs", &health.queued_jobs.to_string(), "queued_jobs", + view.csrf_token, ); append_health_job_row( rows, - "Recent completed jobs", + "Completed jobs", &health.recent_completed_jobs.to_string(), "recent_completed_jobs", + view.csrf_token, ); append_health_job_row( rows, "Failed jobs", &health.failed_jobs.to_string(), "failed_jobs", + view.csrf_token, + ); + append_health_job_row( + rows, + "Backup jobs", + health.backup_jobs, + "backup_jobs", + view.csrf_token, + ); + append_health_job_row( + rows, + "Restore jobs", + health.restore_jobs, + "restore_jobs", + view.csrf_token, ); - append_health_job_row(rows, "Backup jobs", health.backup_jobs, "backup_jobs"); - append_health_job_row(rows, "Restore jobs", health.restore_jobs, "restore_jobs"); } fn append_health_row(out: &mut String, label: &str, value: &str) { @@ -116,7 +136,24 @@ fn append_health_row(out: &mut String, label: &str, value: &str) { ); } -fn append_health_job_row(out: &mut String, label: &str, value: &str, key: &str) { +fn append_health_job_row(out: &mut String, label: &str, value: &str, key: &str, csrf_token: &str) { + if key == "failed_jobs" { + let disabled_attr = if value == "0" { + r#" disabled aria-disabled="true""# + } else { + "" + }; + let _ = write!( + out, + r#"
"#, + label = escape_html(label), + key = escape_html(key), + value = escape_html(value), + csrf = escape_html(csrf_token), + disabled_attr = disabled_attr, + ); + return; + } if matches!(key, "failed_jobs" | "recent_completed_jobs") { let target = if key == "failed_jobs" { "failed" @@ -125,7 +162,7 @@ fn append_health_job_row(out: &mut String, label: &str, value: &str, key: &str) }; let _ = write!( out, - r#"
{label}
"#, + r#"
"#, label = escape_html(label), key = escape_html(key), target = escape_html(target), @@ -143,15 +180,15 @@ fn append_health_job_row(out: &mut String, label: &str, value: &str, key: &str) } fn render_recent_jobs_panel() -> String { - r#"