From fd61fb3a933559eb44dbc783eaa9576a0bf05ced Mon Sep 17 00:00:00 2001 From: csd113 Date: Fri, 13 Mar 2026 16:44:03 -0700 Subject: [PATCH 1/2] implemented API --- Cargo.lock | 820 ++++++++++++++++++++++++++--- Cargo.toml | 6 +- deny.toml | 97 +--- src/chan_net/chan_net.rs | 217 ++++++++ src/chan_net/command.rs | 210 ++++++++ src/chan_net/export.rs | 31 ++ src/chan_net/import.rs | 152 ++++++ src/chan_net/ledger.rs | 33 ++ src/chan_net/mod.rs | 170 ++++++ src/chan_net/poll.rs | 104 ++++ src/chan_net/refresh.rs | 96 ++++ src/chan_net/selective_snapshot.rs | 500 ++++++++++++++++++ src/chan_net/snapshot.rs | 152 ++++++ src/chan_net/status.rs | 35 ++ src/config.rs | 68 +++ src/db/chan_net.rs | 232 ++++++++ src/db/mod.rs | 17 + src/error.rs | 5 + src/main.rs | 10 +- src/middleware/mod.rs | 7 + src/models.rs | 45 ++ src/server/cli.rs | 13 +- src/server/server.rs | 23 +- 23 files changed, 2866 insertions(+), 177 deletions(-) create mode 100644 src/chan_net/chan_net.rs create mode 100644 src/chan_net/command.rs create mode 100644 src/chan_net/export.rs create mode 100644 src/chan_net/import.rs create mode 100644 src/chan_net/ledger.rs create mode 100644 src/chan_net/mod.rs create mode 100644 src/chan_net/poll.rs create mode 100644 src/chan_net/refresh.rs create mode 100644 src/chan_net/selective_snapshot.rs create mode 100644 src/chan_net/snapshot.rs create mode 100644 src/chan_net/status.rs create mode 100644 src/db/chan_net.rs diff --git a/Cargo.lock b/Cargo.lock index 6dee69e..3990ff7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -43,9 +43,9 @@ dependencies = [ [[package]] name = "anstream" -version = "0.6.21" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" dependencies = [ "anstyle", "anstyle-parse", @@ -58,15 +58,15 @@ dependencies = [ [[package]] name = "anstyle" -version = "1.0.13" +version = "1.0.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" [[package]] name = "anstyle-parse" -version = "0.2.7" +version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" dependencies = [ "utf8parse", ] @@ -105,7 +105,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -208,6 +208,12 @@ dependencies = [ "tracing", ] +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + [[package]] name = "base64ct" version = "1.8.3" @@ -301,6 +307,23 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + +[[package]] +name = "chacha20" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f8d983286843e49675a4b7a2d174efe136dc93a18d69130dd18198a6c167601" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.0", + "rand_core 0.10.0", +] + [[package]] name = "chrono" version = "0.4.44" @@ -317,9 +340,9 @@ dependencies = [ [[package]] name = "clap" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a" +checksum = "b193af5b67834b676abd72466a96c1024e6a6ad978a1f484bd90b85c94041351" dependencies = [ "clap_builder", "clap_derive", @@ -327,9 +350,9 @@ dependencies = [ [[package]] name = "clap_builder" -version = "4.5.60" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876" +checksum = "714a53001bf66416adb0e2ef5ac857140e7dc3a0c48fb28b2f10762fc4b5069f" dependencies = [ "anstream", "anstyle", @@ -339,9 +362,9 @@ dependencies = [ [[package]] name = "clap_derive" -version = "4.5.55" +version = "4.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5" +checksum = "1110bd8a634a1ab8cb04345d8d878267d57c3cf1b38d91b71af6686408bbca6a" dependencies = [ "heck", "proc-macro2", @@ -351,9 +374,9 @@ dependencies = [ [[package]] name = "clap_lex" -version = "1.0.0" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" [[package]] name = "color_quant" @@ -363,9 +386,9 @@ checksum = "3d7b894f5411737b7867f4827955924d7c254fc9f4d91a6aad6b097804b1018b" [[package]] name = "colorchoice" -version = "1.0.4" +version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" [[package]] name = "compression-codecs" @@ -413,6 +436,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b2a41393f66f16b0823bb79094d54ac5fbd34ab292ddafb9a0456ac9f87d201" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -487,6 +519,17 @@ dependencies = [ "subtle", ] +[[package]] +name = "displaydoc" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "encoding_rs" version = "0.8.35" @@ -653,8 +696,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi", + "wasm-bindgen", ] [[package]] @@ -664,9 +709,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -678,6 +725,7 @@ dependencies = [ "cfg-if", "libc", "r-efi 6.0.0", + "rand_core 0.10.0", "wasip2", "wasip3", ] @@ -818,6 +866,24 @@ dependencies = [ "pin-utils", "smallvec", "tokio", + "want", +] + +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", + "webpki-roots", ] [[package]] @@ -826,13 +892,21 @@ version = "0.1.20" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" dependencies = [ + "base64", "bytes", + "futures-channel", + "futures-util", "http", "http-body", "hyper", + "ipnet", + "libc", + "percent-encoding", "pin-project-lite", + "socket2", "tokio", "tower-service", + "tracing", ] [[package]] @@ -859,17 +933,119 @@ dependencies = [ "cc", ] +[[package]] +name = "icu_collections" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4c6b649701667bbe825c3b7e6388cb521c23d88644678e83c0c4d0a621a34b43" +dependencies = [ + "displaydoc", + "potential_utf", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edba7861004dd3714265b4db54a3c390e880ab658fec5f7db895fae2046b5bb6" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f6c8828b67bf8908d82127b2054ea1b4427ff0230ee9141c54251934ab1b599" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7aedcccd01fc5fe81e6b489c15b247b8b0690feb23304303a9e560f37efc560a" + +[[package]] +name = "icu_properties" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "020bfc02fe870ec3a66d93e677ccca0562506e5872c650f893269e08615d74ec" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616c294cf8d725c6afcd8f55abc17c56464ef6211f9ed59cccffe534129c77af" + +[[package]] +name = "icu_provider" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85962cf0ce02e1e0a629cc34e7ca3e373ce20dda4c4d7294bbd0bf1fdb59e614" +dependencies = [ + "displaydoc", + "icu_locale_core", + "writeable", + "yoke", + "zerofrom", + "zerotrie", + "zerovec", +] + [[package]] name = "id-arena" version = "2.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d3067d79b975e8844ca9eb072e16b31c3c1c36928edf9c6789548c524d0d954" +[[package]] +name = "idna" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3b0875f23caa03898994f6ddc501886a45c7d3d62d04d2d90788d47be1b1e4de" +dependencies = [ + "idna_adapter", + "smallvec", + "utf8_iter", +] + +[[package]] +name = "idna_adapter" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3acae9609540aa318d1bc588455225fb2085b9ed0c4f6bd0d9d5bcd86f1a0344" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + [[package]] name = "image" -version = "0.25.9" +version = "0.25.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" dependencies = [ "bytemuck", "byteorder-lite", @@ -880,8 +1056,8 @@ dependencies = [ "num-traits", "png", "tiff", - "zune-core 0.5.1", - "zune-jpeg 0.5.12", + "zune-core", + "zune-jpeg", ] [[package]] @@ -906,6 +1082,22 @@ dependencies = [ "serde_core", ] +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "iri-string" +version = "0.7.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c91338f0783edbd6195decb37bae672fd3b165faffb89bf7b9e6942f8b1a731a" +dependencies = [ + "memchr", + "serde", +] + [[package]] name = "is_terminal_polyfill" version = "1.70.2" @@ -961,9 +1153,9 @@ checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" [[package]] name = "libc" -version = "0.2.182" +version = "0.2.183" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6800badb6cb2082ffd7b6a67e6125bb39f18782f793520caee8cb8846be06112" +checksum = "b5b646652bf6661599e1da8901b3b9522896f01e736bad5f723fe7a3a27f899d" [[package]] name = "libsqlite3-sys" @@ -982,6 +1174,12 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" +[[package]] +name = "litemap" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6373607a59f0be73a39b6fe456b8192fcc3585f602af20751600e974dd455e77" + [[package]] name = "lock_api" version = "0.4.14" @@ -997,6 +1195,12 @@ version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "matchers" version = "0.2.0" @@ -1057,9 +1261,9 @@ dependencies = [ [[package]] name = "moxcms" -version = "0.7.11" +version = "0.8.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" dependencies = [ "num-traits", "pxfm", @@ -1114,9 +1318,9 @@ dependencies = [ [[package]] name = "once_cell" -version = "1.21.3" +version = "1.21.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" [[package]] name = "once_cell_polyfill" @@ -1195,6 +1399,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "potential_utf" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b73949432f5e2a09657003c25bca5e19a0e9c84f8058ca374f49e0ebe605af77" +dependencies = [ + "zerovec", +] + [[package]] name = "powerfmt" version = "0.2.0" @@ -1241,6 +1454,61 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls", + "socket2", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2", + "tracing", + "windows-sys 0.60.2", +] + [[package]] name = "quote" version = "1.0.45" @@ -1294,6 +1562,17 @@ dependencies = [ "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc266eb313df6c5c09c1c7b1fbe2510961e5bcd3add930c1e31f7ed9da0feff8" +dependencies = [ + "chacha20", + "getrandom 0.4.2", + "rand_core 0.10.0", +] + [[package]] name = "rand_chacha" version = "0.9.0" @@ -1322,6 +1601,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c8d0fd677905edcbeedbf2edb6494d676f0e98d54d5cf9bda0b061cb8fb8aba" + [[package]] name = "redox_syscall" version = "0.5.18" @@ -1360,6 +1645,60 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64", + "bytes", + "futures-core", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-util", + "js-sys", + "log", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "quinn", + "rustls", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-rustls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "webpki-roots", +] + +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rsqlite-vfs" version = "0.1.0" @@ -1385,6 +1724,12 @@ dependencies = [ "sqlite-wasm-rs", ] +[[package]] +name = "rustc-hash" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d" + [[package]] name = "rustchan" version = "1.1.0" @@ -1406,6 +1751,7 @@ dependencies = [ "r2d2_sqlite", "rand_core 0.6.4", "regex", + "reqwest", "rusqlite", "serde", "serde_json", @@ -1438,6 +1784,41 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "web-time", + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" @@ -1553,7 +1934,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -1602,12 +1983,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1628,6 +2009,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strsim" version = "0.11.1" @@ -1656,12 +2043,26 @@ name = "sync_wrapper" version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" +dependencies = [ + "futures-core", +] + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] [[package]] name = "tempfile" -version = "3.26.0" +version = "3.27.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" dependencies = [ "fastrand", "getrandom 0.4.2", @@ -1701,16 +2102,16 @@ dependencies = [ [[package]] name = "tiff" -version = "0.10.3" +version = "0.11.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +checksum = "b63feaf3343d35b6ca4d50483f94843803b0f51634937cc2ec519fc32232bc52" dependencies = [ "fax", "flate2", "half", "quick-error", "weezl", - "zune-jpeg 0.4.21", + "zune-jpeg", ] [[package]] @@ -1744,6 +2145,31 @@ dependencies = [ "time-core", ] +[[package]] +name = "tinystr" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42d3e9c45c09de15d06dd8acf5f4e0e399e85927b7f00711024eb7ae10fa4869" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -1772,6 +2198,16 @@ dependencies = [ "syn", ] +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -1787,9 +2223,9 @@ dependencies = [ [[package]] name = "toml" -version = "1.0.4+spec-1.1.0" +version = "1.0.6+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c94c3321114413476740df133f0d8862c61d87c8d26f04c6841e033c8c80db47" +checksum = "399b1124a3c9e16766831c6bba21e50192572cdd98706ea114f9502509686ffc" dependencies = [ "indexmap", "serde_core", @@ -1856,12 +2292,14 @@ dependencies = [ "http-body-util", "http-range-header", "httpdate", + "iri-string", "mime", "mime_guess", "percent-encoding", "pin-project-lite", "tokio", "tokio-util", + "tower", "tower-layer", "tower-service", "tracing", @@ -1947,9 +2385,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.22" +version = "0.3.23" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2f30143827ddab0d256fd843b7a66d164e9f271cfa0dde49142c5ca0ca291f1e" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" dependencies = [ "matchers", "nu-ansi-term", @@ -1966,6 +2404,12 @@ dependencies = [ "tracing-serde", ] +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + [[package]] name = "typed-path" version = "0.12.3" @@ -1996,6 +2440,30 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + +[[package]] +name = "url" +version = "2.5.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff67a8a4397373c3ef660812acab3268222035010ab8680ec4215f38ba3d0eed" +dependencies = [ + "form_urlencoded", + "idna", + "percent-encoding", + "serde", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + [[package]] name = "utf8parse" version = "0.2.2" @@ -2004,13 +2472,14 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.21.0" +version = "1.22.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b672338555252d43fd2240c714dc444b8c6fb0a5c5335e65a07bba7742735ddb" +checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" dependencies = [ "getrandom 0.4.2", "js-sys", - "rand", + "rand 0.10.0", + "serde_core", "wasm-bindgen", ] @@ -2032,6 +2501,15 @@ version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" +[[package]] +name = "want" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa7760aed19e106de2c7c0b581b509f2f25d3dacaf737cb82ac61bc6d760b0e" +dependencies = [ + "try-lock", +] + [[package]] name = "wasi" version = "0.11.1+wasi-snapshot-preview1" @@ -2069,6 +2547,20 @@ dependencies = [ "wasm-bindgen-shared", ] +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.64" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9c5522b3a28661442748e09d40924dfb9ca614b21c00d3fd135720e48b67db8" +dependencies = [ + "cfg-if", + "futures-util", + "js-sys", + "once_cell", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "wasm-bindgen-macro" version = "0.2.114" @@ -2135,6 +2627,35 @@ dependencies = [ "semver", ] +[[package]] +name = "web-sys" +version = "0.3.91" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "854ba17bb104abfb26ba36da9729addc7ce7f06f5c0f90f3c391f8461cca21f9" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "weezl" version = "0.1.12" @@ -2200,13 +2721,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.60.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f2f500e4d28234f72040990ec9d39e3a6b950f9f22d3dba18416c35882612bcb" dependencies = [ - "windows-targets", + "windows-targets 0.53.5", ] [[package]] @@ -2218,6 +2748,22 @@ dependencies = [ "windows-link", ] +[[package]] +name = "windows-targets" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b724f72796e036ab90c1021d4780d4d3d648aca59e491e6b98e725b84e99973" +dependencies = [ + "windows_aarch64_gnullvm 0.52.6", + "windows_aarch64_msvc 0.52.6", + "windows_i686_gnu 0.52.6", + "windows_i686_gnullvm 0.52.6", + "windows_i686_msvc 0.52.6", + "windows_x86_64_gnu 0.52.6", + "windows_x86_64_gnullvm 0.52.6", + "windows_x86_64_msvc 0.52.6", +] + [[package]] name = "windows-targets" version = "0.53.5" @@ -2225,58 +2771,106 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4945f9f551b88e0d65f3db0bc25c33b8acea4d9e41163edf90dcd0b19f9069f3" dependencies = [ "windows-link", - "windows_aarch64_gnullvm", - "windows_aarch64_msvc", - "windows_i686_gnu", - "windows_i686_gnullvm", - "windows_i686_msvc", - "windows_x86_64_gnu", - "windows_x86_64_gnullvm", - "windows_x86_64_msvc", + "windows_aarch64_gnullvm 0.53.1", + "windows_aarch64_msvc 0.53.1", + "windows_i686_gnu 0.53.1", + "windows_i686_gnullvm 0.53.1", + "windows_i686_msvc 0.53.1", + "windows_x86_64_gnu 0.53.1", + "windows_x86_64_gnullvm 0.53.1", + "windows_x86_64_msvc 0.53.1", ] +[[package]] +name = "windows_aarch64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a4622180e7a0ec044bb555404c800bc9fd9ec262ec147edd5989ccd0c02cd3" + [[package]] name = "windows_aarch64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a9d8416fa8b42f5c947f8482c43e7d89e73a173cead56d044f6a56104a6d1b53" +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + [[package]] name = "windows_aarch64_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b9d782e804c2f632e395708e99a94275910eb9100b2114651e04744e9b125006" +[[package]] +name = "windows_i686_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e9b5ad5ab802e97eb8e295ac6720e509ee4c243f69d781394014ebfe8bbfa0b" + [[package]] name = "windows_i686_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "960e6da069d81e09becb0ca57a65220ddff016ff2d6af6a223cf372a506593a3" +[[package]] +name = "windows_i686_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + [[package]] name = "windows_i686_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fa7359d10048f68ab8b09fa71c3daccfb0e9b559aed648a8f95469c27057180c" +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + [[package]] name = "windows_i686_msvc" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e7ac75179f18232fe9c285163565a57ef8d3c89254a30685b57d83a38d326c2" +[[package]] +name = "windows_x86_64_gnu" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "147a5c80aabfbf0c7d901cb5895d1de30ef2907eb21fbbab29ca94c5b08b1a78" + [[package]] name = "windows_x86_64_gnu" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9c3842cdd74a865a8066ab39c8a7a473c0778a3f29370b5fd6b4b9aa7df4a499" +[[package]] +name = "windows_x86_64_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "24d5b23dc417412679681396f2b49f3de8c1473deb516bd34410872eff51ed0d" + [[package]] name = "windows_x86_64_gnullvm" version = "0.53.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0ffa179e2d07eee8ad8f57493436566c7cc30ac536a3379fdf008f47f6bb7ae1" +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + [[package]] name = "windows_x86_64_msvc" version = "0.53.1" @@ -2285,9 +2879,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "0.7.14" +version = "0.7.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a5364e9d77fcdeeaa6062ced926ee3381faa2ee02d3eb83a5c27a8825540829" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" [[package]] name = "wit-bindgen" @@ -2377,20 +2971,109 @@ dependencies = [ "wasmparser", ] +[[package]] +name = "writeable" +version = "0.6.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9edde0db4769d2dc68579893f2306b26c6ecfbe0ef499b013d731b7b9247e0b9" + +[[package]] +name = "yoke" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72d6e5c6afb84d73944e5cedb052c4680d5657337201555f9f2a16b7406d4954" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + [[package]] name = "zerocopy" -version = "0.8.40" +version = "0.8.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a789c6e490b576db9f7e6b6d661bcc9799f7c0ac8352f56ea20193b2681532e5" +checksum = "f2578b716f8a7a858b7f02d5bd870c14bf4ddbbcf3a4c05414ba6503640505e3" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.40" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e6cc098ea4d3bd6246687de65af3f920c430e236bee1e3bf2e441463f08a02f" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "zerofrom" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50cc42e0333e05660c3587f3bf9d0478688e15d870fab3346451ce7f8c9fbea5" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "synstructure", +] + +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + +[[package]] +name = "zerotrie" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2a59c17a5562d507e4b54960e8569ebee33bee890c70aa3fe7b97e85a9fd7851" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c28719294829477f525be0186d13efa9a3c602f7ec202ca9e353d310fb9a002" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f65c489a7071a749c849713807783f70672b28094011623e200cb86dcb835953" +checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", @@ -2463,12 +3146,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "zune-core" -version = "0.4.12" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" - [[package]] name = "zune-core" version = "0.5.1" @@ -2477,18 +3154,9 @@ checksum = "cb8a0807f7c01457d0379ba880ba6322660448ddebc890ce29bb64da71fb40f9" [[package]] name = "zune-jpeg" -version = "0.4.21" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" -dependencies = [ - "zune-core 0.4.12", -] - -[[package]] -name = "zune-jpeg" -version = "0.5.12" +version = "0.5.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "410e9ecef634c709e3831c2cfdb8d9c32164fae1c67496d5b68fff728eec37fe" +checksum = "ec5f41c76397b7da451efd19915684f727d7e1d516384ca6bd0ec43ec94de23c" dependencies = [ - "zune-core 0.5.1", + "zune-core", ] diff --git a/Cargo.toml b/Cargo.toml index 487703c..b39eb0c 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -67,7 +67,7 @@ kamadak-exif = "0.5" clap = { version = "4", features = ["derive"] } -uuid = { version = "1", features = ["v4"] } +uuid = { version = "1", features = ["v4", "serde"] } chrono = { version = "0.4", features = ["serde"] } dashmap = "6" parking_lot = "0.12" @@ -81,6 +81,10 @@ regex = "1" # by_index_raw which is now by_index_without_decompression in zip 8). zip = { version = "8", default-features = false, features = ["deflate"] } +# Step 1.1: reqwest for federation push (chan_refresh) and pull (chan_poll). +# rustls-tls avoids a native OpenSSL dependency — single static binary stays intact. +reqwest = { version = "0.12", default-features = false, features = ["multipart", "json", "rustls-tls"] } + tracing = "0.1" tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt", "json"] } tracing-appender = "0.2" diff --git a/deny.toml b/deny.toml index ed610e5..a325889 100644 --- a/deny.toml +++ b/deny.toml @@ -1,105 +1,44 @@ # cargo-deny configuration for RustChan # https://embarkstudios.github.io/cargo-deny/ -# -# Run: cargo deny check -# CI: cargo deny --log-level error check licenses advisories sources -# --------------------------------------------------------------------------- -# Graph -# --------------------------------------------------------------------------- [graph] -# Only check dependencies that are actually compiled for the host target. -# Dev-only and build-script deps are included by default; that's fine. all-features = false -# --------------------------------------------------------------------------- -# Security advisories (RustSec database) -# --------------------------------------------------------------------------- [advisories] -# Check all dependencies (workspace + transitive) for unmaintained status. unmaintained = "all" -# Yanked versions in Cargo.lock are always an error. yanked = "deny" -# No crates are ignored; add entries here if a patched advisory is accepted: -# ignore = ["RUSTSEC-0000-0000"] -# --------------------------------------------------------------------------- -# Licence policy -# --------------------------------------------------------------------------- [licenses] -# Confidence threshold for licence detection (0.0–1.0). -# 0.8 is the cargo-deny default; lower values accept fuzzier matches. confidence-threshold = 0.8 -# Every SPDX licence expression that appears in this dependency tree must -# appear in this allow-list. Licences are checked per-crate; a crate with -# an AND expression (e.g. "(MIT OR Apache-2.0) AND BSD-2-Clause") must have -# every component covered. allow = [ - # The two dominant Rust ecosystem licences — almost every crate uses one - # or both of these. "MIT", "Apache-2.0", - - # LLVM-exception variant used by a handful of compiler-support crates - # (e.g. rustc-demangle, compiler_builtins). "Apache-2.0 WITH LLVM-exception", - - # BSD family "BSD-2-Clause", "BSD-3-Clause", - - # Unicode data tables (unicode-ident, unicode-xid, etc.) "Unicode-3.0", - - # Compression / image crates (flate2, miniz_oxide, zlib-rs, png, gif) "Zlib", - - # Public domain equivalents "Unlicense", + # Used by ring, rustls-webpki, untrusted (via rustls/reqwest) + "ISC", + # Used by webpki-roots (Mozilla CA certificate bundle) + "CDLA-Permissive-2.0", ] -# Per-crate exceptions for licences that don't appear in `allow` above but -# are acceptable for a specific package. Add entries here rather than -# broadening `allow` when a licence is unusual. [[licenses.exceptions]] -# encoding_rs uses a compound expression: -# "(MIT OR Apache-2.0) AND BSD-2-Clause" -# MIT, Apache-2.0, and BSD-2-Clause are all in `allow`; this exception is -# listed here for documentation clarity in case cargo-deny evaluates the -# compound expression as a single token. name = "encoding_rs" version = "*" allow = ["MIT", "Apache-2.0", "BSD-2-Clause"] -# --------------------------------------------------------------------------- -# Dependency bans -# --------------------------------------------------------------------------- [bans] -# Allow multiple versions of the same crate (common in large dependency -# graphs; Cargo already handles version isolation correctly). multiple-versions = "warn" -# No crates are banned outright; add entries here as needed: -# [[bans.deny]] -# name = "some-crate" -# version = "*" -# reason = "use X instead" - -# Unavoidable transitive version splits — different subtrees pin different -# majors/minors and cannot be unified without upstream changes. - -# argon2 → password-hash → rand_core 0.6; uuid → rand 0.9 → rand_core 0.9 +# argon2 → password-hash → rand_core 0.6 [[bans.skip]] name = "rand_core" version = "0.6" -[[bans.skip]] -name = "rand_core" -version = "0.9" - -# rand_core 0.6 pulls getrandom 0.2; rand 0.9 pulls getrandom 0.3; -# tempfile pulls getrandom 0.4 via rustix [[bans.skip]] name = "getrandom" version = "0.2" @@ -112,7 +51,6 @@ version = "0.3" name = "getrandom" version = "0.4" -# getrandom 0.3/0.4 each pull a different r-efi minor [[bans.skip]] name = "r-efi" version = "5" @@ -121,7 +59,6 @@ version = "5" name = "r-efi" version = "6" -# dashmap 6 uses hashbrown 0.14; rusqlite/zip use hashbrown 0.16 [[bans.skip]] name = "hashbrown" version = "0.14" @@ -130,41 +67,19 @@ version = "0.14" name = "hashbrown" version = "0.16" -# socket2 (tokio) uses windows-sys 0.60; clap/tempfile/rustix use 0.61 -[[bans.skip]] -name = "windows-sys" -version = "0.60" - [[bans.skip]] name = "windows-sys" version = "0.61" -# image directly uses zune-jpeg 0.5.x + zune-core 0.5.x, while its -# tiff sub-dependency requires zune-jpeg 0.4.x + zune-core 0.4.x. -# These are distinct semver ranges so Cargo cannot unify them without -# an upstream tiff release; skipping both versions here. -[[bans.skip]] -name = "zune-core" -version = "0.4" - [[bans.skip]] name = "zune-core" version = "0.5" -[[bans.skip]] -name = "zune-jpeg" -version = "0.4" - [[bans.skip]] name = "zune-jpeg" version = "0.5" -# --------------------------------------------------------------------------- -# Crate sources -# --------------------------------------------------------------------------- [sources] -# Only crates.io is trusted by default. Add git or local entries below if -# you use path or git dependencies. unknown-registry = "deny" unknown-git = "deny" -allow-registry = ["https://github.com/rust-lang/crates.io-index"] +allow-registry = ["https://github.com/rust-lang/crates.io-index"] \ No newline at end of file diff --git a/src/chan_net/chan_net.rs b/src/chan_net/chan_net.rs new file mode 100644 index 0000000..7dc9269 --- /dev/null +++ b/src/chan_net/chan_net.rs @@ -0,0 +1,217 @@ +// db/chan_net.rs — Database helpers for the ChanNet federation and RustWave gateway layers. +// +// Three functions live here: +// +// insert_board_if_absent — idempotent board upsert used during federation import. +// insert_post_if_absent — INSERT OR IGNORE into the chan_net_posts mirror table. +// insert_reply_into_thread — write path from the RustWave gateway into the live posts +// table. Validates thread existence, board membership, and +// archive status before inserting. Bumps thread reply_count +// and bumped_at on success. +// +// Imports from crate::models::SnapshotPost. SnapshotPost lives in src/models.rs +// so that this db-layer file can import it without a layering inversion. +// chan_net::snapshot re-exports the type so all other call-sites compile unchanged. +// +// Schema verification notes (checked against src/db/posts.rs): +// - Post body column is `body` (NOT `content`) +// - Post author column is `name` (NOT `author`) +// - `body_html` is NOT NULL — set to plain text content for gateway-inserted posts +// - `ip_hash` is nullable — NULL for gateway posts (no inbound IP available) +// - `deletion_token` is NOT NULL — a fresh UUID v4 is generated per insert +// - `created_at` has a DB-level default of unixepoch() — omitted from INSERT +// - `is_op` is 0 for all replies +// +// Phase 7 changes: insert_reply_into_thread stub replaced with full implementation. + +use anyhow::Result; +use rusqlite::Connection; +use uuid::Uuid; + +// SnapshotPost is defined in src/models.rs (not chan_net::snapshot) so that +// this file, which lives in the db layer, can import it without creating a +// layering inversion. chan_net::snapshot re-exports the type so that all +// other call-sites continue to compile unchanged. +use crate::models::SnapshotPost; + +// ── insert_board_if_absent ──────────────────────────────────────────────────── + +/// Ensure a board with the given `short_name` exists in the `boards` table. +/// +/// If a board with that short name already exists, returns its `id` without +/// modifying any data. If it does not exist, inserts a new board with safe +/// default values and returns the new `id`. +/// +/// This is called during a federation import for every board in the incoming +/// snapshot. The "absent" check is a SELECT before INSERT so that existing +/// board metadata (name, NSFW flag, thread limits, etc.) set by the local admin +/// is never overwritten by federation data. +pub fn insert_board_if_absent(conn: &Connection, short_name: &str, title: &str) -> Result { + let existing: Option = conn + .query_row( + "SELECT id FROM boards WHERE short_name = ?1", + rusqlite::params![short_name], + |row| row.get(0), + ) + .ok(); + + if let Some(id) = existing { + return Ok(id); + } + + conn.execute( + "INSERT INTO boards (short_name, title, description, nsfw, max_threads, bump_limit) + VALUES (?1, ?2, '', 0, 100, 300)", + rusqlite::params![short_name, title], + )?; + Ok(conn.last_insert_rowid()) +} + +// ── insert_post_if_absent ───────────────────────────────────────────────────── + +/// Insert a remote post into the `chan_net_posts` federation mirror table. +/// +/// Uses `INSERT OR IGNORE` so duplicate imports (same `remote_post_id` / +/// `board_id` pair) are silently discarded. The unique index +/// `idx_chan_net_posts_remote` provides the DB-level deduplication guarantee +/// even after a ledger reset (server restart). Posts imported here are NOT +/// inserted into the live `posts` table — they are held in the mirror table +/// and are not visible to web users browsing boards. +/// +/// SECURITY: Only the five text fields defined in `SnapshotPost` are written. +/// No file paths, MIME types, thumbnail paths, or binary data are accepted. +pub fn insert_post_if_absent( + conn: &Connection, + post: &SnapshotPost, + local_board_id: i64, +) -> Result<()> { + conn.execute( + "INSERT OR IGNORE INTO chan_net_posts + (remote_post_id, board_id, author, content, remote_ts) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + post.post_id as i64, + local_board_id, + &post.author, + &post.content, + post.timestamp as i64, + ], + )?; + Ok(()) +} + +// ── insert_reply_into_thread ────────────────────────────────────────────────── + +/// Insert a reply from RustWave directly into the live `posts` table. +/// +/// This is the ONLY write path from the RustWave gateway into the live forum +/// data. The reply becomes immediately visible to web users browsing the board. +/// +/// # Preconditions (enforced inside this function) +/// +/// - The thread identified by `thread_id` must exist. +/// - The thread must belong to the board identified by `board_short_name`. +/// - The thread must not be archived (`archived = 0`). +/// +/// Returns the new post's row id on success, or an error if any precondition +/// is violated. No insert is attempted when a precondition fails. +/// +/// # Column mapping (verified against src/db/posts.rs) +/// +/// The `author` parameter is written to the `name` column. +/// The `content` parameter is written to both the `body` and `body_html` columns. +/// `body_html` is set to the plain-text content — the forum render pipeline is +/// not invoked for gateway-inserted posts, so storing plain text here is safe +/// and avoids introducing an HTML-injection risk. +/// `ip_hash` is NULL — no client IP is available for gateway posts. +/// `deletion_token` is a freshly generated UUID v4 string. +/// `is_op` is 0 — gateway posts are always replies. +/// `created_at` is set by the database default (`unixepoch()`); the `timestamp` +/// parameter from RustWave is informational and is not written to the posts table +/// to avoid clock-skew issues between nodes. +/// +/// After a successful insert, `bump_thread` is called to increment `reply_count` +/// and advance `bumped_at`. This mirrors the behaviour of the normal post-creation +/// path in `src/db/threads.rs`. +pub fn insert_reply_into_thread( + conn: &Connection, + board_short_name: &str, + thread_id: i64, + author: &str, + content: &str, + _timestamp: i64, +) -> Result { + // ── Precondition check ──────────────────────────────────────────────────── + // + // Verify the thread exists, belongs to the correct board, and is not + // archived before attempting any write. The JOIN on boards ensures that a + // valid thread_id on the wrong board is rejected, not silently accepted. + let row: Option<(i64, i64)> = conn + .query_row( + "SELECT t.id, t.board_id + FROM threads t + JOIN boards b ON t.board_id = b.id + WHERE t.id = ?1 + AND b.short_name = ?2 + AND t.archived = 0", + rusqlite::params![thread_id, board_short_name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok(); + + let (_, board_id) = row.ok_or_else(|| { + anyhow::anyhow!( + "Thread {} on board '{}' does not exist or is archived", + thread_id, + board_short_name + ) + })?; + + // ── Insert into the live posts table ────────────────────────────────────── + // + // Only the text fields are written. No file paths, MIME types, thumbnail + // paths, or binary data are accepted from the gateway. + // + // `body_html` is set to the same value as `body`. Gateway posts bypass the + // normal Markdown/BBCode render pipeline; storing plain text in body_html + // is intentional and safe — the web layer will display it verbatim inside + // the pre-escaped template helper. + // + // `deletion_token` is a fresh UUID v4 so that local admins can delete + // gateway-inserted posts through the normal deletion interface. + let deletion_token = Uuid::new_v4().to_string(); + + let post_id: i64 = conn.query_row( + "INSERT INTO posts + (thread_id, board_id, name, body, body_html, + ip_hash, deletion_token, is_op) + VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, 0) + RETURNING id", + rusqlite::params![ + thread_id, + board_id, + author, + content, + content, // body_html = plain text content + deletion_token, + ], + |row| row.get(0), + )?; + + // ── Bump the thread ─────────────────────────────────────────────────────── + // + // Mirror the normal post-creation path: advance bumped_at and increment + // reply_count. This call is not co-transactional with the INSERT above + // (same documented limitation as the main post-creation path in threads.rs + // MED-6). A crash between the two statements leaves reply_count one behind, + // which is an advisory counter — not a data integrity failure. + conn.execute( + "UPDATE threads + SET bumped_at = unixepoch(), + reply_count = reply_count + 1 + WHERE id = ?1", + rusqlite::params![thread_id], + )?; + + Ok(post_id) +} diff --git a/src/chan_net/command.rs b/src/chan_net/command.rs new file mode 100644 index 0000000..57adf56 --- /dev/null +++ b/src/chan_net/command.rs @@ -0,0 +1,210 @@ +// chan_net/command.rs — RustWave gateway handler. +// +// POST /chan/command accepts a raw JSON body (Content-Type: application/json), +// deserialises it into the `Command` enum (dispatched via +// #[serde(tag = "type", rename_all = "snake_case")]), and returns a ZIP data +// package with a timestamped Content-Disposition filename. +// +// Commands: +// full_export — all boards, active threads, posts (optional `since` delta) +// board_export — one board, active threads, posts (optional `since` delta) +// thread_export — one thread, all its posts (optional `since` delta) +// archive_export — archived threads for one board (no `since` support) +// force_refresh — everything including archives (no `since`, logs warn!) +// reply_push — insert a reply into the live posts table +// +// Text content only — no media fields ever cross this interface. +// +// Security hardening (Step 7.3 checklist): +// ✔ DefaultBodyLimit::max(CONFIG.chan_net_command_max_body) applied in mod.rs +// ✔ reply_push: content validated at ≤ 32,768 chars before any DB write +// ✔ reply_push: author validated at ≤ 255 chars before any DB write +// ✔ insert_reply_into_thread verifies thread exists, board matches, not archived +// ✔ No board or thread is created as a side effect — unknown targets return 400 +// ✔ `scope` field in GwMetadata set correctly by each builder +// ✔ force_refresh emits tracing::warn! at call site (selective_snapshot.rs) +// ✔ Content-Disposition filename follows exact naming convention from build plan +// +// Phase 8 fix: two AppError::Internal calls passed String values (via .to_string()) +// instead of anyhow::Error. Fixed: db.get() now uses ? directly (From +// impl), and the JoinError from spawn_blocking uses anyhow::anyhow!(e). + +use axum::{extract::State, http::header, response::IntoResponse, Json}; +use serde::Deserialize; + +use super::selective_snapshot::{ + build_archive_snapshot, build_board_snapshot, build_force_refresh_snapshot, + build_full_snapshot, build_thread_snapshot, +}; +use crate::{error::AppError, middleware::AppState}; + +// ── Command enum ────────────────────────────────────────────────────────────── + +/// All commands accepted by `POST /chan/command`. +/// +/// Serialised as a JSON object with a `"type"` discriminant field. +/// Field names use `snake_case`. See the `RustWave` API reference section of +/// `channet_build_plan.md` for full field documentation and example payloads. +#[derive(Deserialize, Debug)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum Command { + /// Return all boards, all active (non-archived) threads, and all their + /// posts. If `since` is provided, only posts newer than that Unix timestamp + /// are included (delta mode). Thread metadata is always emitted in full. + FullExport { since: Option }, + + /// Return all active threads and posts for a single board. + /// If `since` is provided, only newer posts are included. + BoardExport { board: String, since: Option }, + + /// Return all posts for a single thread. + /// If `since` is provided, only newer posts are included. + ThreadExport { thread_id: i64, since: Option }, + + /// Return all archived threads and their posts for a single board. + /// `since` is not accepted — archives are static. + ArchiveExport { board: String }, + + /// Return everything: all boards, all active threads, all archived threads, + /// all posts. No timestamp filtering. Intended for initial sync and + /// disaster recovery. Use sparingly — this is the heaviest possible response. + ForceRefresh, + + /// Insert a reply into the live `posts` table. + /// + /// This is the only command that writes to the database. The reply is + /// immediately visible to web users browsing the board. + /// + /// Preconditions (enforced by `insert_reply_into_thread`): + /// - Thread `thread_id` must exist. + /// - Thread must belong to board `board`. + /// - Thread must not be archived. + /// + /// `content` must be ≤ 32,768 characters. + /// `author` must be ≤ 255 characters. + /// These are validated before any DB write; violations return 400. + ReplyPush { + board: String, + thread_id: i64, + author: String, + content: String, + timestamp: u64, + }, +} + +// ── Handler ─────────────────────────────────────────────────────────────────── + +/// `POST /chan/command` +/// +/// Accepts a JSON body (Content-Type: application/json, body ≤ +/// `CONFIG.chan_net_command_max_body` bytes — enforced by `DefaultBodyLimit` +/// applied in `chan_router()`). +/// +/// Returns a ZIP package (Content-Type: application/zip) with a +/// `Content-Disposition: attachment; filename=".zip"` header. +/// +/// Filename conventions (Unix timestamp = seconds since epoch at dispatch time): +/// `full_export` → `rustchan_full_.zip` +/// `board_export` → `rustchan_board__.zip` +/// `thread_export` → `rustchan_thread__.zip` +/// `archive_export` → `rustchan_archive__.zip` +/// `force_refresh` → `rustchan_force_refresh_.zip` +/// `reply_push` → `rustchan_thread__reply_confirmed_.zip` +/// +/// Error mapping: +/// Snapshot builder / DB errors → 400 Bad Request (anyhow errors from the +/// blocking task are mapped by `.map_err(|e| AppError::BadRequest(e.to_string()))`) +/// Tokio join errors → 500 Internal Server Error +pub async fn chan_command( + State(state): State, + Json(cmd): Json, +) -> Result { + // Use ? directly — AppError implements From. + let conn = state.db.get()?; + + let (zip_bytes, filename) = + tokio::task::spawn_blocking(move || -> anyhow::Result<(Vec, String)> { + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + match cmd { + Command::FullExport { since } => { + let (zip, _) = build_full_snapshot(&conn, since)?; + Ok((zip, format!("rustchan_full_{now}.zip"))) + } + + Command::BoardExport { board, since } => { + let (zip, _) = build_board_snapshot(&conn, &board, since)?; + Ok((zip, format!("rustchan_board_{board}_{now}.zip"))) + } + + Command::ThreadExport { thread_id, since } => { + let (zip, _) = build_thread_snapshot(&conn, thread_id, since)?; + Ok((zip, format!("rustchan_thread_{thread_id}_{now}.zip"))) + } + + Command::ArchiveExport { board } => { + let (zip, _) = build_archive_snapshot(&conn, &board)?; + Ok((zip, format!("rustchan_archive_{board}_{now}.zip"))) + } + + Command::ForceRefresh => { + let (zip, _) = build_force_refresh_snapshot(&conn)?; + Ok((zip, format!("rustchan_force_refresh_{now}.zip"))) + } + + Command::ReplyPush { + board, + thread_id, + author, + content, + timestamp, + } => { + // ── Input validation — must happen before any DB write ── + if content.len() > 32_768 { + anyhow::bail!("Reply content exceeds maximum length of 32,768 characters"); + } + if author.len() > 255 { + anyhow::bail!("Author name exceeds maximum length of 255 characters"); + } + + // ── DB write ─────────────────────────────────────────── + // insert_reply_into_thread validates thread existence, + // board membership, and archive status internally. + crate::db::chan_net::insert_reply_into_thread( + &conn, + &board, + thread_id, + &author, + &content, + timestamp.cast_signed(), + )?; + + // Return the updated thread as the confirmation payload. + // `since = None` so that the full thread (including the new + // post) is always included in the response ZIP. + let (zip, _) = build_thread_snapshot(&conn, thread_id, None)?; + Ok(( + zip, + format!("rustchan_thread_{thread_id}_reply_confirmed_{now}.zip"), + )) + } + } + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))? // JoinError → 500 + .map_err(|e| AppError::BadRequest(e.to_string()))?; // anyhow::Error → 400 + + let disposition = format!("attachment; filename=\"{filename}\""); + + Ok(( + axum::http::StatusCode::OK, + [ + (header::CONTENT_TYPE, "application/zip".to_string()), + (header::CONTENT_DISPOSITION, disposition), + ], + zip_bytes, + )) +} diff --git a/src/chan_net/export.rs b/src/chan_net/export.rs new file mode 100644 index 0000000..ae076d7 --- /dev/null +++ b/src/chan_net/export.rs @@ -0,0 +1,31 @@ +// chan_net/export.rs — Federation export handler. +// +// Step 2.4 +// +// POST /chan/export builds a full snapshot of all boards and active +// (non-archived) threads via snapshot::build_snapshot and returns the ZIP +// bytes with Content-Type: application/zip. + +use crate::{error::AppError, middleware::AppState}; +use axum::{extract::State, http::header, response::IntoResponse}; + +pub async fn chan_export( + State(state): State, +) -> Result { + let conn = state + .db + .get() + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let (zip_bytes, _tx_id) = + tokio::task::spawn_blocking(move || super::snapshot::build_snapshot(&conn)) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))? + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + Ok(( + axum::http::StatusCode::OK, + [(header::CONTENT_TYPE, "application/zip")], + zip_bytes, + )) +} diff --git a/src/chan_net/import.rs b/src/chan_net/import.rs new file mode 100644 index 0000000..26f76be --- /dev/null +++ b/src/chan_net/import.rs @@ -0,0 +1,152 @@ +// chan_net/import.rs — Federation import handler. +// +// POST /chan/import receives a raw snapshot ZIP body, performs deduplication +// via the in-memory TxLedger, validates the payload schema, writes boards and +// posts to the `chan_net_posts` mirror table, then records the tx_id in the +// ledger. +// +// `do_import()` is also called by `poll.rs` when draining the RustWave +// broadcast queue, so it is `pub` and accepts pre-read `bytes::Bytes` rather +// than reading from an Axum extractor internally. +// +// Order of operations inside do_import (MUST NOT be changed without updating +// the security hardening checklist in channet_build_plan.md § 6.3): +// +// 1. Unpack and parse the ZIP (rejects unknown filenames — path traversal guard) +// 2. Ed25519 signature check — log-and-skip if signature is present (not yet verified) +// 3. Check TxLedger — reject duplicate tx_ids BEFORE any DB write +// 4. Schema validation — all posts must have a non-empty board field and +// content within the 32 768-character limit +// 5. DB writes (single spawn_blocking: boards then posts) +// 6. Record tx_id in ledger after confirmed successful write + +use axum::{extract::State, http::StatusCode, response::IntoResponse, Json}; +use serde_json::json; +use tokio_util::bytes; + +use super::snapshot::unpack_snapshot; +use crate::{error::AppError, middleware::AppState}; + +// ── do_import ───────────────────────────────────────────────────────────────── + +/// Core import logic shared by `chan_import` (POST /chan/import) and +/// `chan_poll` (which drains the `RustWave` broadcast queue). +/// +/// Returns the number of posts in the snapshot on success. +/// +/// # Errors +/// +/// - `AppError::BadRequest` — ZIP is malformed, contains unexpected files, +/// or a post fails schema validation. +/// - `AppError::Conflict` — the `tx_id` in `metadata` has already been +/// imported (duplicate snapshot). +/// - `AppError::Internal` — DB connection failure or `spawn_blocking` panic. +pub async fn do_import(state: &AppState, bytes: bytes::Bytes) -> Result { + // ── 1. Unpack ──────────────────────────────────────────────────────────── + let (boards, posts, metadata) = + unpack_snapshot(&bytes).map_err(|e| AppError::BadRequest(e.to_string()))?; + + // ── 2. Ed25519 signature check ─────────────────────────────────────────── + // Verification is not yet implemented. If a signature is present we log a + // warning and continue rather than silently ignoring it. A future phase + // will verify the signature and reject snapshots that fail verification. + // + // SECURITY NOTE: Accepting signed snapshots without verification means + // signature presence currently offers no authenticity guarantee. Do NOT + // promote this instance to production without completing Ed25519 + // verification (see channet_build_plan.md § 6.3). + if let Some(ref sig) = metadata.signature { + tracing::warn!( + tx_id = %metadata.tx_id, + signature = %sig, + "Snapshot carries an Ed25519 signature — verification not yet \ + implemented; signature will not be checked until Phase N. \ + Proceeding without verification." + ); + } + + // ── 3. Ledger check — must happen BEFORE any DB write ─────────────────── + { + let ledger_arc = state + .chan_ledger + .as_ref() + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("ChanNet ledger not initialised")))?; + + // parking_lot::Mutex::lock() never poisons — no unwrap needed. + let ledger = ledger_arc.lock(); + if ledger.contains(&metadata.tx_id) { + return Err(AppError::Conflict("Snapshot already imported".into())); + } + } // ledger guard released here + + // ── 4. Schema validation — before any DB write ─────────────────────────── + for post in &posts { + if post.board.trim().is_empty() { + return Err(AppError::BadRequest(format!( + "Post {} has an empty board field", + post.post_id + ))); + } + if post.content.len() > 32_768 { + return Err(AppError::BadRequest(format!( + "Post {} content exceeds the 32 768-character limit", + post.post_id + ))); + } + } + + let post_count = posts.len(); + let tx_id = metadata.tx_id; + + // ── 5. DB writes — all in one spawn_blocking ──────────────────────────── + let conn = state.db.get()?; + + tokio::task::spawn_blocking(move || -> anyhow::Result<()> { + for board in &boards { + // board.id is the short_name string (e.g. "tech", "b") + let board_id = + crate::db::chan_net::insert_board_if_absent(&conn, &board.id, &board.title)?; + + for post in posts.iter().filter(|p| p.board == board.id) { + crate::db::chan_net::insert_post_if_absent(&conn, post, board_id)?; + } + } + Ok(()) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("spawn_blocking panic: {e}")))? + .map_err(AppError::Internal)?; + + // ── 6. Record tx_id in ledger after confirmed successful write ─────────── + { + let ledger_arc = state + .chan_ledger + .as_ref() + .ok_or_else(|| AppError::Internal(anyhow::anyhow!("ChanNet ledger not initialised")))?; + + ledger_arc.lock().insert(tx_id); + } + + Ok(post_count) +} + +// ── chan_import ─────────────────────────────────────────────────────────────── + +/// POST /chan/import — receives a federation snapshot ZIP as raw bytes. +/// +/// Returns `{"imported": N}` on success, where N is the number of posts in +/// the received snapshot (not necessarily the number actually written — posts +/// that already exist in `chan_net_posts` are silently skipped by +/// INSERT OR IGNORE). +/// +/// The request body limit is enforced by `DefaultBodyLimit::max(CONFIG.chan_net_max_body)` +/// applied in `chan_router()`. This handler never reads more than that limit. +pub async fn chan_import( + State(state): State, + body: axum::body::Bytes, +) -> Result { + let imported = do_import(&state, body) + .await + .map_err(super::ChanError::from)?; + Ok((StatusCode::OK, Json(json!({ "imported": imported })))) +} diff --git a/src/chan_net/ledger.rs b/src/chan_net/ledger.rs new file mode 100644 index 0000000..86f7907 --- /dev/null +++ b/src/chan_net/ledger.rs @@ -0,0 +1,33 @@ +// chan_net/ledger.rs — Transaction deduplication ledger for federation imports. +// +// TxLedger tracks UUID transaction IDs from imported snapshots so that the +// same snapshot is never applied twice within a server session. +// +// Step 1.3 + +use std::collections::HashSet; +use uuid::Uuid; + +// TODO: TxLedger is in-memory only. A server restart clears seen tx_ids, +// allowing a re-import of the same snapshot. DB persistence is a future extension. +// The unique DB index on chan_net_posts provides a DB-level deduplication +// safety net regardless of ledger state. +pub struct TxLedger { + seen: HashSet, +} + +impl TxLedger { + pub fn new() -> Self { + Self { + seen: HashSet::new(), + } + } + + pub fn contains(&self, id: &Uuid) -> bool { + self.seen.contains(id) + } + + pub fn insert(&mut self, id: Uuid) { + self.seen.insert(id); + } +} diff --git a/src/chan_net/mod.rs b/src/chan_net/mod.rs new file mode 100644 index 0000000..d5c5df7 --- /dev/null +++ b/src/chan_net/mod.rs @@ -0,0 +1,170 @@ +// chan_net/mod.rs — ChanNet API module root. +// +// Runs on a second TCP listener (default 127.0.0.1:7070), separate from the +// main forum port. Activated with the --chan-net CLI flag. +// +// Two independent layers: +// Layer 1 — Federation sync (Phases 1–6): node-to-node ZIP exchange +// Layer 2 — RustWave gateway (Phase 7): JSON command in, ZIP package out +// +// Rate-limit middleware is intentionally excluded — all traffic on this +// listener is machine-to-machine. +// +// Step 1.4 + +pub mod command; +pub mod export; +pub mod import; +pub mod poll; +pub mod refresh; +pub mod selective_snapshot; +pub mod snapshot; +pub mod status; + +use crate::config::CONFIG; +use crate::error::AppError; +use crate::middleware::AppState; +use axum::{ + extract::DefaultBodyLimit, + http::StatusCode, + middleware::{self, Next}, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use serde_json::json; + +// ── ChanError ───────────────────────────────────────────────────────────────── +// +// All `/chan/*` routes are machine-to-machine. They must never return the HTML +// error pages that `AppError::into_response` renders for browser-facing routes. +// `ChanError` wraps `AppError` and overrides `IntoResponse` to emit JSON: +// +// { "error": "" } +// +// with the same HTTP status code that `AppError` would have produced. + +/// JSON-rendering error type for all `/chan/*` handlers. +pub struct ChanError(pub AppError); + +impl From for ChanError { + fn from(e: AppError) -> Self { + Self(e) + } +} + +// Forward the common conversions that handler code uses with `?`. +impl From for ChanError { + fn from(e: r2d2::Error) -> Self { + Self(AppError::from(e)) + } +} + +impl From for ChanError { + fn from(e: rusqlite::Error) -> Self { + Self(AppError::from(e)) + } +} + +impl From for ChanError { + fn from(e: anyhow::Error) -> Self { + Self(AppError::Internal(e)) + } +} + +impl IntoResponse for ChanError { + fn into_response(self) -> Response { + let (status, message) = match self.0 { + AppError::NotFound(msg) => (StatusCode::NOT_FOUND, msg), + AppError::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg), + AppError::Forbidden(msg) => (StatusCode::FORBIDDEN, msg), + AppError::BannedUser { reason, .. } => (StatusCode::FORBIDDEN, reason), + AppError::Conflict(msg) => (StatusCode::CONFLICT, msg), + AppError::UploadTooLarge(msg) => (StatusCode::PAYLOAD_TOO_LARGE, msg), + AppError::InvalidMediaType(msg) => (StatusCode::UNSUPPORTED_MEDIA_TYPE, msg), + AppError::RateLimited => ( + StatusCode::TOO_MANY_REQUESTS, + "Posting too fast.".to_string(), + ), + AppError::DbBusy => ( + StatusCode::SERVICE_UNAVAILABLE, + "Database busy — retry shortly.".to_string(), + ), + AppError::Internal(e) => { + tracing::error!("ChanNet internal error: {:?}", e); + ( + StatusCode::INTERNAL_SERVER_ERROR, + "An internal error occurred.".to_string(), + ) + } + AppError::Api { + status, + detail, + endpoint, + } => { + tracing::error!( + status, + endpoint = endpoint.as_deref().unwrap_or("unknown"), + "ChanNet API error: {detail}", + ); + ( + StatusCode::BAD_GATEWAY, + format!("API error {status}: {detail}"), + ) + } + }; + + (status, Json(json!({ "error": message }))).into_response() + } +} + +// ── Body-limit JSON middleware ───────────────────────────────────────────────── +// +// `DefaultBodyLimit` rejects oversized bodies before the handler runs, and its +// built-in rejection renders plain text (StatusCode 413, body: +// "Failed to buffer request body: …"). That bypasses our `ChanError` JSON +// rendering. This middleware sits *outside* the body-limit layer and +// intercepts any 413 response, replacing it with a proper JSON error body. + +async fn json_body_limit_error(req: axum::http::Request, next: Next) -> Response { + let response = next.run(req).await; + if response.status() == StatusCode::PAYLOAD_TOO_LARGE { + return ( + StatusCode::PAYLOAD_TOO_LARGE, + Json(json!({ "error": "Request body too large" })), + ) + .into_response(); + } + response +} + +/// Build the `ChanNet` router. +/// +/// All `/chan/*` routes are wired here. `DefaultBodyLimit` is applied +/// per-route so that the tight 8 KiB JSON command limit does not +/// accidentally apply to the ZIP import route and vice-versa. +pub fn chan_router(state: AppState) -> Router { + Router::new() + // ── Status ────────────────────────────────────────────────────────── + .route("/chan/status", get(status::chan_status)) + // ── RustWave gateway — raw JSON in, ZIP data package out ───────────── + // + // The json_body_limit_error middleware is applied *outside* the + // DefaultBodyLimit layer so that 413 rejections are rendered as JSON + // instead of the default plain-text "Failed to buffer request body". + .route( + "/chan/command", + post(command::chan_command) + .layer(DefaultBodyLimit::max(CONFIG.chan_net_command_max_body)) + .layer(middleware::from_fn(json_body_limit_error)), + ) + // ── Federation sync — ZIP in, ZIP out ──────────────────────────────── + .route("/chan/export", post(export::chan_export)) + .route( + "/chan/import", + post(import::chan_import).layer(DefaultBodyLimit::max(CONFIG.chan_net_max_body)), + ) + .route("/chan/refresh", post(refresh::chan_refresh)) + .route("/chan/poll", post(poll::chan_poll)) + .with_state(state) +} diff --git a/src/chan_net/poll.rs b/src/chan_net/poll.rs new file mode 100644 index 0000000..faa1fb0 --- /dev/null +++ b/src/chan_net/poll.rs @@ -0,0 +1,104 @@ +// chan_net/poll.rs — Federation incoming handler. +// Fully implemented in Phase 5 (Step 5.1). +// +// POST /chan/poll drains RustWave's broadcast queue by fetching and importing +// up to MAX_POLL_ITERATIONS (50) snapshots per call. Skips AppError::Conflict +// errors (already-seen tx_ids) silently. Propagates all other errors. +// +// Queue-empty detection: if RustWave responds with Content-Type +// application/json and a body of {"status":"empty"}, the loop exits cleanly. +// +// Implementation note: the response body is always consumed via .bytes() +// before any JSON inspection. Calling .json() and then .bytes() on the same +// reqwest::Response would fail — the body stream is single-pass. +// +// Phase 8 fix: two AppError::Internal calls passed String values (via format!()) +// instead of anyhow::Error. Fixed by using anyhow::anyhow!() in both cases. + +use super::import::do_import; +use super::refresh::HTTP_CLIENT; +use crate::{config::CONFIG, error::AppError, middleware::AppState}; +use axum::{extract::State, response::IntoResponse, Json}; +use serde_json::json; + +const MAX_POLL_ITERATIONS: usize = 50; + +/// POST /chan/poll +/// +/// Drains `RustWave`'s incoming broadcast queue. Each iteration performs a GET +/// to `{rustwave_url}/broadcast/incoming`. The loop exits when: +/// +/// - `RustWave` returns a non-2xx status (peer-side error or empty queue signal) +/// - `RustWave` returns `Content-Type: application/json` with `{"status":"empty"}` +/// - `MAX_POLL_ITERATIONS` snapshots have been fetched +/// - A non-Conflict import error is encountered (propagated to the caller) +/// +/// Returns `{"imported": N}` where N is the total number of posts imported +/// across all snapshots in this drain cycle. Posts skipped by INSERT OR IGNORE +/// are not counted. Snapshots whose `tx_id` was already recorded in the `TxLedger` +/// contribute 0 to the count and are silently skipped. +pub async fn chan_poll( + State(state): State, +) -> Result { + let url = format!("{}/broadcast/incoming", CONFIG.rustwave_url); + let mut imported_count = 0usize; + + for _ in 0..MAX_POLL_ITERATIONS { + // ── Fetch next item from the broadcast queue ───────────────────── + let resp = HTTP_CLIENT + .get(&url) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("RustWave poll failed: {e}")))?; + + if !resp.status().is_success() { + // Non-2xx: treat as end-of-queue or transient peer error. + break; + } + + // Capture Content-Type before consuming the body — header access is + // not possible after calling .bytes(). + let content_type = resp + .headers() + .get(axum::http::header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .to_string(); + + // ── Consume body exactly once ───────────────────────────────────── + // IMPORTANT: reqwest::Response body is a single-pass stream. We read + // the full body into `bytes` here and reuse that buffer for both the + // JSON sentinel check and the ZIP import. Calling `.json()` before + // `.bytes()` would leave the response in a moved / partially-read + // state, making the subsequent `.bytes()` call fail. + let bytes = resp.bytes().await.map_err(|e| { + AppError::Internal(anyhow::anyhow!("Failed to read RustWave body: {e}")) + })?; + + // ── Empty-queue sentinel check ──────────────────────────────────── + if content_type.contains("application/json") { + // Parse without failing hard — a malformed sentinel is not a + // fatal error; we fall through and let do_import reject the + // non-ZIP bytes as a BadRequest. + if let Ok(body) = serde_json::from_slice::(&bytes) { + if body.get("status").and_then(|s| s.as_str()) == Some("empty") { + break; + } + } + // JSON body that is NOT the sentinel: fall through. do_import + // will reject it with AppError::BadRequest (not a valid ZIP). + } + + // ── Import snapshot ─────────────────────────────────────────────── + match do_import(&state, bytes).await { + Ok(count) => imported_count = imported_count.saturating_add(count), + Err(AppError::Conflict(_)) => { + // tx_id already in TxLedger — snapshot was seen before. + // This is expected during normal operation; skip silently. + } + Err(e) => return Err(super::ChanError::from(e)), + } + } + + Ok(Json(json!({ "imported": imported_count }))) +} diff --git a/src/chan_net/refresh.rs b/src/chan_net/refresh.rs new file mode 100644 index 0000000..cf1d172 --- /dev/null +++ b/src/chan_net/refresh.rs @@ -0,0 +1,96 @@ +// chan_net/refresh.rs — Federation outgoing handler. +// Fully implemented in Phase 4 (Step 4.1). +// +// POST /chan/refresh builds a full snapshot and pushes it to RustWave +// /broadcast/transmit as multipart. Holds the shared HTTP_CLIENT static +// (LazyLock) reused by poll.rs. +// +// Phase 8 fix: all AppError::Internal calls in this file previously passed +// String values (via .to_string() or format!()) to AppError::Internal, which +// takes anyhow::Error. These have been corrected to use anyhow::anyhow!() or +// direct ? propagation where a From impl exists. + +use crate::{config::CONFIG, error::AppError, middleware::AppState}; +use axum::{extract::State, response::IntoResponse, Json}; +use serde_json::json; +use std::sync::LazyLock; + +/// Shared reqwest client — initialised once, reused for all outgoing calls +/// (refresh + poll). The 30-second timeout covers slow `RustWave` responses +/// during high-load broadcast operations. +#[allow(clippy::expect_used)] // LazyLock static init — no error-propagation path available +pub static HTTP_CLIENT: LazyLock = LazyLock::new(|| { + reqwest::Client::builder() + .timeout(std::time::Duration::from_secs(30)) + .build() + .expect("Failed to build reqwest client") +}); + +/// POST /chan/refresh +/// +/// Builds a full in-memory snapshot ZIP of all boards and active posts, then +/// pushes it to `RustWave`'s `/broadcast/transmit` endpoint as a multipart POST. +/// +/// On success, returns both the local snapshot `tx_id` and the broadcast `tx_id` +/// echoed back by `RustWave`: +/// ```json +/// { "status": "ok", "local_tx_id": "...", "broadcast_tx_id": "..." } +/// ``` +/// +/// Returns `500 Internal Server Error` if the snapshot build fails, if +/// `RustWave` is unreachable, or if `RustWave` responds with a non-2xx status. +pub async fn chan_refresh( + State(state): State, +) -> Result { + // ── Build snapshot on a blocking thread ────────────────────────────── + // Use ? directly — AppError implements From. + let conn = state.db.get()?; + + let (zip_bytes, tx_id) = + tokio::task::spawn_blocking(move || super::snapshot::build_snapshot(&conn)) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))? // JoinError + .map_err(AppError::Internal)?; // anyhow::Error from build_snapshot + + // ── Assemble multipart form ─────────────────────────────────────────── + let part = reqwest::multipart::Part::bytes(zip_bytes) + .file_name("snapshot.zip") + .mime_str("application/zip") + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let form = reqwest::multipart::Form::new().part("snapshot", part); + + // ── POST to RustWave ───────────────────────────────────────────────── + let url = format!("{}/broadcast/transmit", CONFIG.rustwave_url); + + let resp = HTTP_CLIENT + .post(&url) + .multipart(form) + .send() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("RustWave unreachable: {e}")))?; + + if !resp.status().is_success() { + return Err( + AppError::Internal(anyhow::anyhow!("RustWave returned {}", resp.status())).into(), + ); + } + + // ── Parse broadcast tx_id from RustWave response ───────────────────── + let body: serde_json::Value = resp + .json() + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!(e)))?; + + let broadcast_tx_id = body + .get("tx_id") + .and_then(|v| v.as_str()) + .unwrap_or("") + .to_string(); + + Ok(Json(json!({ + "status": "ok", + "local_tx_id": tx_id.to_string(), + "broadcast_tx_id": broadcast_tx_id, + }))) +} diff --git a/src/chan_net/selective_snapshot.rs b/src/chan_net/selective_snapshot.rs new file mode 100644 index 0000000..254e177 --- /dev/null +++ b/src/chan_net/selective_snapshot.rs @@ -0,0 +1,500 @@ +// chan_net/selective_snapshot.rs — RustWave gateway snapshot builders. +// +// Five scoped ZIP builders for the RustWave gateway layer (Phase 7). +// These builders are entirely separate from snapshot.rs (federation layer) +// so that their contracts remain independently evolvable. +// +// Builders: +// build_full_snapshot(conn, since) — all boards, active threads only +// build_board_snapshot(conn, board, since) — one board, active threads only +// build_thread_snapshot(conn, thread_id, since) — one thread +// build_archive_snapshot(conn, board) — archived threads, no since support +// build_force_refresh_snapshot(conn) — everything including archives, +// no timestamp filtering, +// emits tracing::warn! +// +// All builders return (Vec, Uuid) — the raw ZIP bytes and the transaction ID +// embedded in metadata.json. +// +// SECURITY: GwPost / GwThread / GwBoard carry text fields only — no media columns, +// no file paths, no MIME types, no thumbnail paths. This boundary is enforced at +// the query level: only the columns listed in fetch_posts() are ever selected. +// +// Column verification (checked against src/db/posts.rs and src/db/threads.rs): +// - Post body column: `p.body` (NOT `p.content`) +// - Post author column: `p.name` (NOT `p.author`) +// - Board name column: `b.name` (NOT `b.title`) ← Phase 8 fix +// - Thread subject: `t.subject` — nullable, COALESCE to '' +// - Thread archive: `t.archived` (INTEGER 0/1) +// +// Phase 8 fix: the boards table column is `name`, not `title`. All three +// board-fetch helpers have been corrected from `SELECT short_name, title` +// to `SELECT short_name, name`. + +use std::io::{Cursor, Write}; + +use anyhow::Result; +use rusqlite::Connection; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; +use zip::{write::SimpleFileOptions, ZipWriter}; + +// ── Public structs ──────────────────────────────────────────────────────────── + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GwBoard { + pub short_name: String, + pub title: String, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GwThread { + pub thread_id: i64, + pub board: String, + pub subject: String, + pub created_at: u64, + pub post_count: u64, + pub archived: bool, +} + +/// SECURITY: No media fields. Text content only. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GwPost { + pub post_id: i64, + pub thread_id: i64, + pub board: String, + pub author: String, + pub content: String, + pub timestamp: u64, +} + +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct GwMetadata { + pub generated_at: u64, + pub rustchan_version: String, + pub post_count: u64, + pub tx_id: Uuid, + pub since: Option, + pub is_delta: bool, + pub includes_archive: bool, + /// One of: `"full"` | `"board"` | `"thread"` | `"archive"` | `"force_refresh"` + pub scope: String, +} + +// ── Internal helpers ────────────────────────────────────────────────────────── + +fn now_secs() -> u64 { + std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs() +} + +fn finish_zip(zip: ZipWriter>>) -> Result> { + Ok(zip.finish()?.into_inner()) +} + +// ── Public snapshot builders ────────────────────────────────────────────────── + +/// All boards, all active (non-archived) threads, and all their posts. +/// +/// If `since` is `Some(ts)`, only posts with `created_at > ts` are returned +/// (delta mode). Thread metadata is always emitted in full regardless of `since` +/// so that `RustWave` can maintain a complete thread index. +pub fn build_full_snapshot(conn: &Connection, since: Option) -> Result<(Vec, Uuid)> { + let boards = fetch_all_boards(conn)?; + let threads = fetch_threads(conn, None, false)?; + let posts = fetch_posts(conn, None, None, since, false)?; + + let tx_id = Uuid::new_v4(); + let metadata = GwMetadata { + generated_at: now_secs(), + rustchan_version: env!("CARGO_PKG_VERSION").to_string(), + post_count: posts.len() as u64, + tx_id, + since, + is_delta: since.is_some(), + includes_archive: false, + scope: "full".to_string(), + }; + + let zip = pack_zip(&boards, &threads, &posts, &metadata)?; + Ok((zip, tx_id)) +} + +/// All active (non-archived) threads and posts for a single board. +/// +/// If `since` is `Some(ts)`, only posts with `created_at > ts` are returned. +/// Returns an error if `board_short_name` does not identify a known board. +pub fn build_board_snapshot( + conn: &Connection, + board_short_name: &str, + since: Option, +) -> Result<(Vec, Uuid)> { + let board_id = board_id_by_short_name(conn, board_short_name)?; + let boards = fetch_boards_by_id(conn, board_id)?; + let threads = fetch_threads(conn, Some(board_id), false)?; + let posts = fetch_posts(conn, Some(board_id), None, since, false)?; + + let tx_id = Uuid::new_v4(); + let metadata = GwMetadata { + generated_at: now_secs(), + rustchan_version: env!("CARGO_PKG_VERSION").to_string(), + post_count: posts.len() as u64, + tx_id, + since, + is_delta: since.is_some(), + includes_archive: false, + scope: "board".to_string(), + }; + + let zip = pack_zip(&boards, &threads, &posts, &metadata)?; + Ok((zip, tx_id)) +} + +/// All posts for a single thread. +/// +/// If `since` is `Some(ts)`, only posts with `created_at > ts` are returned. +/// Returns an error if `thread_id` does not identify a known thread. +pub fn build_thread_snapshot( + conn: &Connection, + thread_id: i64, + since: Option, +) -> Result<(Vec, Uuid)> { + let threads = fetch_thread_by_id(conn, thread_id)?; + let board_short = threads + .first() + .map(|t| t.board.clone()) + .ok_or_else(|| anyhow::anyhow!("Thread {thread_id} not found"))?; + + let boards = fetch_boards_by_short_name(conn, &board_short)?; + let posts = fetch_posts(conn, None, Some(thread_id), since, false)?; + + let tx_id = Uuid::new_v4(); + let metadata = GwMetadata { + generated_at: now_secs(), + rustchan_version: env!("CARGO_PKG_VERSION").to_string(), + post_count: posts.len() as u64, + tx_id, + since, + is_delta: since.is_some(), + includes_archive: false, + scope: "thread".to_string(), + }; + + let zip = pack_zip(&boards, &threads, &posts, &metadata)?; + Ok((zip, tx_id)) +} + +/// All archived threads and their posts for a single board. +/// +/// `since` is not supported for archive exports — archives are static by +/// definition once a thread is archived. Always returns the full archive. +/// Returns an error if `board_short_name` does not identify a known board. +pub fn build_archive_snapshot( + conn: &Connection, + board_short_name: &str, +) -> Result<(Vec, Uuid)> { + let board_id = board_id_by_short_name(conn, board_short_name)?; + let boards = fetch_boards_by_id(conn, board_id)?; + let threads = fetch_threads(conn, Some(board_id), true)?; + let posts = fetch_posts(conn, Some(board_id), None, None, true)?; + + let tx_id = Uuid::new_v4(); + let metadata = GwMetadata { + generated_at: now_secs(), + rustchan_version: env!("CARGO_PKG_VERSION").to_string(), + post_count: posts.len() as u64, + tx_id, + since: None, + is_delta: false, + includes_archive: true, + scope: "archive".to_string(), + }; + + let zip = pack_zip(&boards, &threads, &posts, &metadata)?; + Ok((zip, tx_id)) +} + +/// Everything: all boards, all active threads, all archived threads, all posts. +/// +/// Ignores all timestamps. Intended for initial sync and disaster recovery. +/// +/// Emits a `tracing::warn!` to make force-refresh calls visible in the operator +/// log — a full database dump over the gateway is a heavyweight operation. +pub fn build_force_refresh_snapshot(conn: &Connection) -> Result<(Vec, Uuid)> { + tracing::warn!( + "Force refresh snapshot requested — returning full database dump including archives" + ); + + let boards = fetch_all_boards(conn)?; + + let mut threads = fetch_threads(conn, None, false)?; + let mut archived = fetch_threads(conn, None, true)?; + threads.append(&mut archived); + + let mut posts = fetch_posts(conn, None, None, None, false)?; + let mut archive_posts = fetch_posts(conn, None, None, None, true)?; + posts.append(&mut archive_posts); + + let tx_id = Uuid::new_v4(); + let metadata = GwMetadata { + generated_at: now_secs(), + rustchan_version: env!("CARGO_PKG_VERSION").to_string(), + post_count: posts.len() as u64, + tx_id, + since: None, + is_delta: false, + includes_archive: true, + scope: "force_refresh".to_string(), + }; + + let zip = pack_zip(&boards, &threads, &posts, &metadata)?; + Ok((zip, tx_id)) +} + +// ── Private DB helpers ──────────────────────────────────────────────────────── + +fn board_id_by_short_name(conn: &Connection, short_name: &str) -> Result { + conn.query_row( + "SELECT id FROM boards WHERE short_name = ?1", + rusqlite::params![short_name], + |r| r.get(0), + ) + .map_err(|_| anyhow::anyhow!("Board '{short_name}' not found")) +} + +/// Phase 8 fix: `SELECT short_name, name` — the boards table column is `name`, +/// not `title`. `GwBoard.title` is the Rust field name; it maps to the `name` SQL +/// column via positional row.get(1). +fn fetch_all_boards(conn: &Connection) -> Result> { + let mut stmt = conn.prepare("SELECT short_name, name FROM boards ORDER BY id")?; + let rows = stmt + .query_map([], |r| { + Ok(GwBoard { + short_name: r.get(0)?, + title: r.get(1)?, + }) + })? + .collect::>()?; + Ok(rows) +} + +/// Phase 8 fix: `SELECT short_name, name` — see `fetch_all_boards`. +fn fetch_boards_by_id(conn: &Connection, board_id: i64) -> Result> { + let mut stmt = conn.prepare("SELECT short_name, name FROM boards WHERE id = ?1")?; + let rows = stmt + .query_map(rusqlite::params![board_id], |r| { + Ok(GwBoard { + short_name: r.get(0)?, + title: r.get(1)?, + }) + })? + .collect::>()?; + Ok(rows) +} + +/// Phase 8 fix: `SELECT short_name, name` — see `fetch_all_boards`. +fn fetch_boards_by_short_name(conn: &Connection, short_name: &str) -> Result> { + let mut stmt = conn.prepare("SELECT short_name, name FROM boards WHERE short_name = ?1")?; + let rows = stmt + .query_map(rusqlite::params![short_name], |r| { + Ok(GwBoard { + short_name: r.get(0)?, + title: r.get(1)?, + }) + })? + .collect::>()?; + Ok(rows) +} + +/// Fetch threads filtered by board and archive status. +/// +/// If `board_id` is `Some`, only threads belonging to that board are returned. +/// If `archived_only` is `true`, only archived threads are returned; otherwise +/// only active threads are returned. +/// +/// Column verification (checked against src/db/threads.rs): +/// `t.id`, `b.short_name`, `t.subject` (nullable → COALESCE), `t.created_at` (INTEGER), +/// post count (correlated subquery), `t.archived` (INTEGER 0/1). +fn fetch_threads( + conn: &Connection, + board_id: Option, + archived_only: bool, +) -> Result> { + let archived_flag: i64 = i64::from(archived_only); + + let sql = match board_id { + Some(_) => { + "SELECT t.id, b.short_name, COALESCE(t.subject, ''), t.created_at, + (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id), t.archived + FROM threads t JOIN boards b ON t.board_id = b.id + WHERE t.board_id = ?1 AND t.archived = ?2 + ORDER BY t.id" + } + None => { + "SELECT t.id, b.short_name, COALESCE(t.subject, ''), t.created_at, + (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id), t.archived + FROM threads t JOIN boards b ON t.board_id = b.id + WHERE t.archived = ?1 + ORDER BY t.id" + } + }; + + let mut stmt = conn.prepare(sql)?; + + let map_row = |r: &rusqlite::Row| { + Ok(GwThread { + thread_id: r.get(0)?, + board: r.get(1)?, + subject: r.get(2)?, + created_at: r.get::<_, i64>(3)?.cast_unsigned(), + post_count: r.get::<_, i64>(4)?.cast_unsigned(), + archived: r.get::<_, i64>(5)? != 0, + }) + }; + + let rows: Vec = match board_id { + Some(bid) => stmt + .query_map(rusqlite::params![bid, archived_flag], map_row)? + .collect::>()?, + None => stmt + .query_map(rusqlite::params![archived_flag], map_row)? + .collect::>()?, + }; + + Ok(rows) +} + +fn fetch_thread_by_id(conn: &Connection, thread_id: i64) -> Result> { + let mut stmt = conn.prepare( + "SELECT t.id, b.short_name, COALESCE(t.subject, ''), t.created_at, + (SELECT COUNT(*) FROM posts p WHERE p.thread_id = t.id), t.archived + FROM threads t JOIN boards b ON t.board_id = b.id + WHERE t.id = ?1", + )?; + let rows = stmt + .query_map(rusqlite::params![thread_id], |r| { + Ok(GwThread { + thread_id: r.get(0)?, + board: r.get(1)?, + subject: r.get(2)?, + created_at: r.get::<_, i64>(3)?.cast_unsigned(), + post_count: r.get::<_, i64>(4)?.cast_unsigned(), + archived: r.get::<_, i64>(5)? != 0, + }) + })? + .collect::>()?; + Ok(rows) +} + +/// Fetch posts with optional board, thread, timestamp, and archive filters. +/// +/// Parameters: +/// - `board_id`: if `Some`, restrict to posts on that board +/// - `thread_id`: if `Some`, restrict to posts in that thread +/// - `since`: if `Some(ts)`, restrict to posts where `created_at > ts` +/// - `archived_only`: if `true`, only posts in archived threads; if `false`, +/// only posts in active threads +/// +/// The query is built dynamically. The `?1` / `?2` slots are always +/// `archived_flag` and `since_val`. Board and thread filters consume `?3` and +/// `?4` respectively when present. +/// +/// Column verification (checked against src/db/posts.rs): +/// `p.id`, `p.thread_id`, `b.short_name`, `p.name` (author), `p.body` (content), +/// `p.created_at`. No media columns are selected. +fn fetch_posts( + conn: &Connection, + board_id: Option, + thread_id: Option, + since: Option, + archived_only: bool, +) -> Result> { + let archived_flag: i64 = i64::from(archived_only); + let since_val: i64 = since.unwrap_or(0).cast_signed(); + + // Fixed parameters: ?1 = archived_flag, ?2 = since_val. + // Optional parameters appended in order: board_id (?3), thread_id (?3 or ?4). + let mut sql = String::from( + "SELECT p.id, p.thread_id, b.short_name, + COALESCE(p.name, 'anon'), COALESCE(p.body, ''), p.created_at + FROM posts p + JOIN threads t ON p.thread_id = t.id + JOIN boards b ON t.board_id = b.id + WHERE t.archived = ?1 + AND p.created_at > ?2", + ); + + if board_id.is_some() { + sql.push_str(" AND b.id = ?3"); + } + if thread_id.is_some() { + let param_n = if board_id.is_some() { "?4" } else { "?3" }; + sql.push_str(" AND p.thread_id = "); + sql.push_str(param_n); + } + sql.push_str(" ORDER BY p.id"); + + let mut stmt = conn.prepare(&sql)?; + + let map_row = |r: &rusqlite::Row| { + Ok(GwPost { + post_id: r.get(0)?, + thread_id: r.get(1)?, + board: r.get(2)?, + author: r.get(3)?, + content: r.get(4)?, + timestamp: r.get::<_, i64>(5)?.cast_unsigned(), + }) + }; + + let rows: Vec = match (board_id, thread_id) { + (None, None) => stmt + .query_map(rusqlite::params![archived_flag, since_val], map_row)? + .collect::>()?, + (Some(b), None) => stmt + .query_map(rusqlite::params![archived_flag, since_val, b], map_row)? + .collect::>()?, + (None, Some(t)) => stmt + .query_map(rusqlite::params![archived_flag, since_val, t], map_row)? + .collect::>()?, + (Some(b), Some(t)) => stmt + .query_map(rusqlite::params![archived_flag, since_val, b, t], map_row)? + .collect::>()?, + }; + + Ok(rows) +} + +// ── ZIP packing ─────────────────────────────────────────────────────────────── + +/// Produce a ZIP archive containing four JSON files: +/// boards.json — `[GwBoard]` +/// threads.json — `[GwThread]` +/// posts.json — `[GwPost]` +/// metadata.json — `GwMetadata` +fn pack_zip( + boards: &[GwBoard], + threads: &[GwThread], + posts: &[GwPost], + metadata: &GwMetadata, +) -> Result> { + let buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(buf); + let opts = SimpleFileOptions::default(); + + zip.start_file("boards.json", opts)?; + zip.write_all(&serde_json::to_vec(boards)?)?; + + zip.start_file("threads.json", opts)?; + zip.write_all(&serde_json::to_vec(threads)?)?; + + zip.start_file("posts.json", opts)?; + zip.write_all(&serde_json::to_vec(posts)?)?; + + zip.start_file("metadata.json", opts)?; + zip.write_all(&serde_json::to_vec(metadata)?)?; + + finish_zip(zip) +} diff --git a/src/chan_net/snapshot.rs b/src/chan_net/snapshot.rs new file mode 100644 index 0000000..6649919 --- /dev/null +++ b/src/chan_net/snapshot.rs @@ -0,0 +1,152 @@ +// chan_net/snapshot.rs — Federation snapshot builders. +// +// Step 2.1 — Structs: SnapshotBoard, SnapshotPost, SnapshotMetadata are now +// defined in src/models.rs and re-exported here so all existing call-sites +// (snapshot::SnapshotPost, etc.) continue to compile without change. +// Moving the types to models.rs resolves the layering inversion: db/chan_net.rs +// previously imported from `crate::chan_net::snapshot`, which is only reachable +// in the binary crate (chan_net is declared in main.rs, not lib.rs). models.rs +// is re-exported by lib.rs and is accessible from anywhere in the crate. +// +// Step 2.2 — build_snapshot: full ZIP of all boards + active (non-archived) posts +// Step 2.3 — unpack_snapshot: strict-whitelist ZIP parser +// +// Column fix (Phase 8): boards table display-name column is `name`, not `title`. + +// Re-export so that all call-sites using `super::snapshot::SnapshotPost` etc. +// continue to compile without any changes. +pub use crate::models::{SnapshotBoard, SnapshotMetadata, SnapshotPost}; + +// ── build_snapshot ──────────────────────────────────────────────────────────── + +use anyhow::Result; +use rusqlite::Connection; +use std::io::{Cursor, Write}; +use uuid::Uuid; +use zip::{write::SimpleFileOptions, ZipWriter}; + +/// Build a full in-memory snapshot ZIP of all boards and all active +/// (non-archived) posts. +/// +/// Returns ZIP bytes and the transaction UUID for this snapshot. +/// Used by the federation layer (`/chan/export`, `/chan/refresh`). +pub fn build_snapshot(conn: &Connection) -> Result<(Vec, Uuid)> { + // ── Boards ──────────────────────────────────────────────────────────── + // Column is `name` (display name), not `title` — verified against db/mod.rs. + let mut stmt = conn.prepare("SELECT short_name, name FROM boards ORDER BY id")?; + let boards: Vec = stmt + .query_map([], |row| { + Ok(SnapshotBoard { + id: row.get(0)?, + title: row.get(1)?, // SQL `name` → Rust field `title` + }) + })? + .collect::>()?; + + // ── Posts (text columns only — NO media columns, NO archived threads) ─ + let mut stmt = conn.prepare( + "SELECT p.id, b.short_name, p.name, p.body, p.created_at + FROM posts p + JOIN threads t ON p.thread_id = t.id + JOIN boards b ON t.board_id = b.id + WHERE t.archived = 0 + ORDER BY p.id", + )?; + let posts: Vec = stmt + .query_map([], |row| { + Ok(SnapshotPost { + post_id: row.get::<_, i64>(0)?.cast_unsigned(), + board: row.get(1)?, + author: row + .get::<_, Option>(2)? + .unwrap_or_else(|| "anon".to_string()), + content: row.get::<_, Option>(3)?.unwrap_or_default(), + timestamp: row.get::<_, i64>(4)?.cast_unsigned(), + }) + })? + .collect::>()?; + + // ── Metadata ────────────────────────────────────────────────────────── + let tx_id = Uuid::new_v4(); + let now = std::time::SystemTime::now() + .duration_since(std::time::UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + + let metadata = SnapshotMetadata { + generated_at: now, + rustchan_version: env!("CARGO_PKG_VERSION").to_string(), + post_count: posts.len() as u64, + tx_id, + signature: None, + since: None, + is_delta: false, + includes_archive: false, + }; + + // ── Build ZIP ───────────────────────────────────────────────────────── + let buf = Cursor::new(Vec::new()); + let mut zip = ZipWriter::new(buf); + let opts = SimpleFileOptions::default(); + + zip.start_file("boards.json", opts)?; + zip.write_all(&serde_json::to_vec(&boards)?)?; + + zip.start_file("posts.json", opts)?; + zip.write_all(&serde_json::to_vec(&posts)?)?; + + zip.start_file("metadata.json", opts)?; + zip.write_all(&serde_json::to_vec(&metadata)?)?; + + let zip_bytes = zip.finish()?.into_inner(); + Ok((zip_bytes, tx_id)) +} + +// ── unpack_snapshot ─────────────────────────────────────────────────────────── + +/// Unpack and parse a federation snapshot ZIP. +/// +/// Rejects any ZIP that contains files other than the three known names, +/// guarding against path traversal and unexpected content. +pub fn unpack_snapshot( + bytes: &[u8], +) -> anyhow::Result<(Vec, Vec, SnapshotMetadata)> { + use std::io::Read; + + let cursor = Cursor::new(bytes); + let mut zip = zip::ZipArchive::new(cursor)?; + + // Path traversal guard — whitelist only. + for i in 0..zip.len() { + let name = zip.by_index(i)?.name().to_string(); + if !matches!( + name.as_str(), + "boards.json" | "posts.json" | "metadata.json" + ) { + anyhow::bail!("Unexpected file in snapshot ZIP: {name}"); + } + } + + let boards: Vec = { + let mut f = zip.by_name("boards.json")?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + serde_json::from_slice(&buf)? + }; + + let posts: Vec = { + let mut f = zip.by_name("posts.json")?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + serde_json::from_slice(&buf)? + }; + + let metadata: SnapshotMetadata = { + let mut f = zip.by_name("metadata.json")?; + let mut buf = Vec::new(); + f.read_to_end(&mut buf)?; + serde_json::from_slice(&buf)? + }; + + Ok((boards, posts, metadata)) +} diff --git a/src/chan_net/status.rs b/src/chan_net/status.rs new file mode 100644 index 0000000..4d65823 --- /dev/null +++ b/src/chan_net/status.rs @@ -0,0 +1,35 @@ +// chan_net/status.rs — ChanNet health check handler. +// Step 6.1 — Fully implemented. +// +// GET /chan/status returns service name, version, board count, and post count +// as JSON. Used by operators and RustWave to verify connectivity. + +use crate::{error::AppError, middleware::AppState}; +use axum::{extract::State, response::IntoResponse, Json}; +use serde_json::json; + +pub async fn chan_status( + State(state): State, +) -> Result { + let conn = state + .db + .get() + .map_err(|e| AppError::Internal(anyhow::anyhow!("{e}")))?; + + let (boards, posts) = tokio::task::spawn_blocking(move || -> rusqlite::Result<(i64, i64)> { + let boards: i64 = conn.query_row("SELECT COUNT(*) FROM boards", [], |r| r.get(0))?; + let posts: i64 = conn.query_row("SELECT COUNT(*) FROM posts", [], |r| r.get(0))?; + Ok((boards, posts)) + }) + .await + .map_err(|e| AppError::Internal(anyhow::anyhow!("spawn_blocking panic: {e}")))? + .map_err(AppError::from)?; + + Ok(Json(json!({ + "service": "chan-net", + "chan_net": true, + "version": env!("CARGO_PKG_VERSION"), + "boards": boards, + "posts": posts, + }))) +} diff --git a/src/config.rs b/src/config.rs index 5fe03d8..8851902 100644 --- a/src/config.rs +++ b/src/config.rs @@ -87,6 +87,13 @@ struct SettingsFile { /// Defaults to logical CPUs × 4. Increase if DB/render latency is a bottleneck /// under load. blocking_threads: Option, + // ── ChanNet / RustWave gateway ──────────────────────────────────────────── + /// Base URL of the connected `RustWave` instance. + /// Must begin with http:// or https://. Default: . + rustwave_url: Option, + /// Address to bind the second `ChanNet` TCP listener. + /// Default: 127.0.0.1:7070 (loopback-only; not exposed to the internet). + chan_net_bind: Option, } fn load_settings_file() -> SettingsFile { @@ -207,6 +214,17 @@ blocking_threads = 0 # or all existing IP hashes become invalid (bans will stop working). # If you must rotate it, also clear the bans table. cookie_secret = "{secret}" + +# ── ChanNet / RustWave gateway ──────────────────────────────────────────────── +# Uncomment and configure these to enable the ChanNet API (--chan-net flag). + +# Base URL of the connected RustWave instance. +# Must begin with http:// or https://. +# rustwave_url = "http://localhost:7071" + +# Address to bind the second ChanNet TCP listener. +# Keep on loopback unless RustWave runs on a different host. +# chan_net_bind = "127.0.0.1:7070" "# ); @@ -277,6 +295,18 @@ pub struct Config { pub waveform_cache_max_bytes: u64, /// Number of threads in Tokio's blocking pool. Default: logical CPUs × 4. pub blocking_threads: usize, + + // ── ChanNet / RustWave gateway (Step 1.2) ──────────────────────────────── + /// Base URL of the connected `RustWave` instance (must begin with http:// or https://). + /// Validated at startup by `Config::validate()`. + pub rustwave_url: String, + /// Address to bind the second `ChanNet` TCP listener (default 127.0.0.1:7070). + /// Only used when the server is started with `--chan-net`. + pub chan_net_bind: String, + /// Maximum request body size for `/chan/import` (ZIP snapshots). Default: 10 MiB. + pub chan_net_max_body: usize, + /// Maximum request body size for `/chan/command` (raw JSON). Default: 8 KiB. + pub chan_net_command_max_body: usize, } impl Config { @@ -335,6 +365,29 @@ impl Config { hex::encode(b) }; + // ── ChanNet fields (Step 1.2) — computed after cookie_secret ────────── + // Use as_deref() to borrow rather than move the Option fields. + let rustwave_url = env::var("CHAN_RUSTWAVE_URL").unwrap_or_else(|_| { + s.rustwave_url + .as_deref() + .unwrap_or("http://localhost:7071") + .to_string() + }); + let chan_net_bind = env::var("CHAN_NET_BIND").unwrap_or_else(|_| { + s.chan_net_bind + .as_deref() + .unwrap_or("127.0.0.1:7070") + .to_string() + }); + let chan_net_max_body: usize = env::var("CHAN_NET_MAX_BODY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(10 * 1024 * 1024); // 10 MiB default + let chan_net_command_max_body: usize = env::var("CHAN_NET_COMMAND_MAX_BODY") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(8 * 1024); // 8 KiB default — commands are raw JSON, never ZIPs + Self { forum_name, initial_site_subtitle, @@ -415,6 +468,12 @@ impl Config { configured } }, + + // ChanNet fields + rustwave_url, + chan_net_bind, + chan_net_max_body, + chan_net_command_max_body, } } @@ -480,6 +539,15 @@ impl Config { let _ = std::fs::remove_file(probe); } + // Step 1.2: Validate rustwave_url scheme so operators catch + // misconfiguration at startup rather than at first federation call. + if !self.rustwave_url.starts_with("http://") && !self.rustwave_url.starts_with("https://") { + return Err(anyhow::anyhow!( + "CONFIG ERROR: rustwave_url must begin with http:// or https://, got: {}", + self.rustwave_url + )); + } + Ok(()) } } diff --git a/src/db/chan_net.rs b/src/db/chan_net.rs new file mode 100644 index 0000000..3af2c64 --- /dev/null +++ b/src/db/chan_net.rs @@ -0,0 +1,232 @@ +// db/chan_net.rs — Database helpers for the ChanNet federation and RustWave gateway layers. +// +// Three functions live here: +// +// insert_board_if_absent — idempotent board upsert used during federation import. +// insert_post_if_absent — INSERT OR IGNORE into the chan_net_posts mirror table. +// insert_reply_into_thread — write path from the RustWave gateway into the live posts +// table. Validates thread existence, board membership, and +// archive status before inserting. Bumps thread reply_count +// and bumped_at on success. +// +// Imports from crate::models::SnapshotPost. SnapshotPost lives in src/models.rs +// so that this db-layer file can import it without a layering inversion. +// chan_net::snapshot re-exports the type so all other call-sites compile unchanged. +// +// Schema verification notes (checked against src/db/posts.rs): +// - Post body column is `body` (NOT `content`) +// - Post author column is `name` (NOT `author`) +// - `body_html` is NOT NULL — set to plain text content for gateway-inserted posts +// - `ip_hash` is nullable — NULL for gateway posts (no inbound IP available) +// - `deletion_token` is NOT NULL — a fresh UUID v4 is generated per insert +// - `created_at` has a DB-level default of unixepoch() — omitted from INSERT +// - `is_op` is 0 for all replies +// +// Phase 7 changes: insert_reply_into_thread stub replaced with full implementation. + +use anyhow::Result; +use rusqlite::Connection; +use uuid::Uuid; + +// SnapshotPost is defined in src/models.rs (not chan_net::snapshot) so that +// this file, which lives in the db layer, can import it without creating a +// layering inversion. chan_net::snapshot re-exports the type so that all +// other call-sites continue to compile unchanged. +use crate::models::SnapshotPost; + +// ── insert_board_if_absent ──────────────────────────────────────────────────── + +/// Ensure a board with the given `short_name` exists in the `boards` table. +/// +/// If a board with that short name already exists, returns its `id` without +/// modifying any data. If it does not exist, inserts a new board with safe +/// default values and returns the new `id`. +/// +/// This is called during a federation import for every board in the incoming +/// snapshot. The "absent" check is a SELECT before INSERT so that existing +/// board metadata (name, NSFW flag, thread limits, etc.) set by the local admin +/// is never overwritten by federation data. +/// +/// # Errors +/// +/// Returns an error if the SELECT or INSERT statement fails (e.g. DB connection +/// lost, schema mismatch). +pub fn insert_board_if_absent(conn: &Connection, short_name: &str, title: &str) -> Result { + let existing: Option = conn + .query_row( + "SELECT id FROM boards WHERE short_name = ?1", + rusqlite::params![short_name], + |row| row.get(0), + ) + .ok(); + + if let Some(id) = existing { + return Ok(id); + } + + conn.execute( + "INSERT INTO boards (short_name, title, description, nsfw, max_threads, bump_limit) + VALUES (?1, ?2, '', 0, 100, 300)", + rusqlite::params![short_name, title], + )?; + Ok(conn.last_insert_rowid()) +} + +// ── insert_post_if_absent ───────────────────────────────────────────────────── + +/// Insert a remote post into the `chan_net_posts` federation mirror table. +/// +/// Uses `INSERT OR IGNORE` so duplicate imports (same `remote_post_id` / +/// `board_id` pair) are silently discarded. The unique index +/// `idx_chan_net_posts_remote` provides the DB-level deduplication guarantee +/// even after a ledger reset (server restart). Posts imported here are NOT +/// inserted into the live `posts` table — they are held in the mirror table +/// and are not visible to web users browsing boards. +/// +/// SECURITY: Only the five text fields defined in `SnapshotPost` are written. +/// No file paths, MIME types, thumbnail paths, or binary data are accepted. +/// +/// # Errors +/// +/// Returns an error if the INSERT statement fails (e.g. DB connection lost or +/// a NOT NULL constraint is violated by a malformed `SnapshotPost`). +pub fn insert_post_if_absent( + conn: &Connection, + post: &SnapshotPost, + local_board_id: i64, +) -> Result<()> { + conn.execute( + "INSERT OR IGNORE INTO chan_net_posts + (remote_post_id, board_id, author, content, remote_ts) + VALUES (?1, ?2, ?3, ?4, ?5)", + rusqlite::params![ + post.post_id.cast_signed(), + local_board_id, + &post.author, + &post.content, + post.timestamp.cast_signed(), + ], + )?; + Ok(()) +} + +// ── insert_reply_into_thread ────────────────────────────────────────────────── + +/// Insert a reply from `RustWave` directly into the live `posts` table. +/// +/// This is the ONLY write path from the `RustWave` gateway into the live forum +/// data. The reply becomes immediately visible to web users browsing the board. +/// +/// # Preconditions (enforced inside this function) +/// +/// - The thread identified by `thread_id` must exist. +/// - The thread must belong to the board identified by `board_short_name`. +/// - The thread must not be archived (`archived = 0`). +/// +/// Returns the new post's row id on success, or an error if any precondition +/// is violated. No insert is attempted when a precondition fails. +/// +/// # Column mapping (verified against src/db/posts.rs) +/// +/// The `author` parameter is written to the `name` column. +/// The `content` parameter is written to both the `body` and `body_html` columns. +/// `body_html` is set to the plain-text content — the forum render pipeline is +/// not invoked for gateway-inserted posts, so storing plain text here is safe +/// and avoids introducing an HTML-injection risk. +/// `ip_hash` is NULL — no client IP is available for gateway posts. +/// `deletion_token` is a freshly generated UUID v4 string. +/// `is_op` is 0 — gateway posts are always replies. +/// `created_at` is set by the database default (`unixepoch()`); the `timestamp` +/// parameter from `RustWave` is informational and is not written to the posts table +/// to avoid clock-skew issues between nodes. +/// +/// After a successful insert, `bump_thread` is called to increment `reply_count` +/// and advance `bumped_at`. This mirrors the behaviour of the normal post-creation +/// path in `src/db/threads.rs`. +/// +/// # Errors +/// +/// - Returns an error if the thread does not exist, belongs to a different board, +/// or is archived (precondition failure). +/// - Returns an error if any DB statement fails (connection lost, constraint +/// violation, or `spawn_blocking` panic). +pub fn insert_reply_into_thread( + conn: &Connection, + board_short_name: &str, + thread_id: i64, + author: &str, + content: &str, + _timestamp: i64, +) -> Result { + // ── Precondition check ──────────────────────────────────────────────────── + // + // Verify the thread exists, belongs to the correct board, and is not + // archived before attempting any write. The JOIN on boards ensures that a + // valid thread_id on the wrong board is rejected, not silently accepted. + let row: Option<(i64, i64)> = conn + .query_row( + "SELECT t.id, t.board_id + FROM threads t + JOIN boards b ON t.board_id = b.id + WHERE t.id = ?1 + AND b.short_name = ?2 + AND t.archived = 0", + rusqlite::params![thread_id, board_short_name], + |row| Ok((row.get(0)?, row.get(1)?)), + ) + .ok(); + + let (_, board_id) = row.ok_or_else(|| { + anyhow::anyhow!( + "Thread {thread_id} on board '{board_short_name}' does not exist or is archived" + ) + })?; + + // ── Insert into the live posts table ────────────────────────────────────── + // + // Only the text fields are written. No file paths, MIME types, thumbnail + // paths, or binary data are accepted from the gateway. + // + // `body_html` is set to the same value as `body`. Gateway posts bypass the + // normal Markdown/BBCode render pipeline; storing plain text in body_html + // is intentional and safe — the web layer will display it verbatim inside + // the pre-escaped template helper. + // + // `deletion_token` is a fresh UUID v4 so that local admins can delete + // gateway-inserted posts through the normal deletion interface. + let deletion_token = Uuid::new_v4().to_string(); + + let post_id: i64 = conn.query_row( + "INSERT INTO posts + (thread_id, board_id, name, body, body_html, + ip_hash, deletion_token, is_op) + VALUES (?1, ?2, ?3, ?4, ?5, NULL, ?6, 0) + RETURNING id", + rusqlite::params![ + thread_id, + board_id, + author, + content, + content, // body_html = plain text content + deletion_token, + ], + |row| row.get(0), + )?; + + // ── Bump the thread ─────────────────────────────────────────────────────── + // + // Mirror the normal post-creation path: advance bumped_at and increment + // reply_count. This call is not co-transactional with the INSERT above + // (same documented limitation as the main post-creation path in threads.rs + // MED-6). A crash between the two statements leaves reply_count one behind, + // which is an advisory counter — not a data integrity failure. + conn.execute( + "UPDATE threads + SET bumped_at = unixepoch(), + reply_count = reply_count + 1 + WHERE id = ?1", + rusqlite::params![thread_id], + )?; + + Ok(post_id) +} diff --git a/src/db/mod.rs b/src/db/mod.rs index 6d8088f..2850bb7 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -49,6 +49,7 @@ use tracing::info; pub mod admin; pub mod boards; +pub mod chan_net; pub mod posts; pub mod threads; @@ -457,6 +458,22 @@ fn create_schema(conn: &rusqlite::Connection) -> Result<()> { (22, "ALTER TABLE boards ADD COLUMN post_cooldown_secs INTEGER NOT NULL DEFAULT 0"), (23, "CREATE INDEX IF NOT EXISTS idx_posts_thread_id ON posts(thread_id)"), (24, "CREATE INDEX IF NOT EXISTS idx_posts_ip_hash ON posts(ip_hash)"), + // Phase 3 — federation mirror table for chan_net imported posts. + // Stores text-only post data from remote nodes; never contains media columns. + // ON DELETE CASCADE keeps this table consistent when a board is removed. + (25, r"CREATE TABLE IF NOT EXISTS chan_net_posts ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + remote_post_id INTEGER NOT NULL, + board_id INTEGER NOT NULL REFERENCES boards(id) ON DELETE CASCADE, + author TEXT NOT NULL DEFAULT 'anon', + content TEXT NOT NULL DEFAULT '', + remote_ts INTEGER NOT NULL, + imported_at INTEGER NOT NULL DEFAULT (unixepoch()) + )"), + // Unique index on (remote_post_id, board_id) — the DB-level deduplication + // safety net. Prevents duplicate imports even after a ledger reset (restart). + (26, "CREATE UNIQUE INDEX IF NOT EXISTS idx_chan_net_posts_remote \ + ON chan_net_posts(remote_post_id, board_id)"), ]; for &(version, sql) in migrations { diff --git a/src/error.rs b/src/error.rs index 7ba78c0..bed7c97 100644 --- a/src/error.rs +++ b/src/error.rs @@ -47,6 +47,10 @@ pub enum AppError { #[error("Invalid media type: {0}")] InvalidMediaType(String), + /// 409 — resource already exists or snapshot already imported + #[error("Conflict: {0}")] + Conflict(String), + /// 429 — rate limited #[error("Rate limited: posting too fast")] RateLimited, @@ -101,6 +105,7 @@ impl IntoResponse for AppError { Self::NotFound(msg) => (StatusCode::NOT_FOUND, msg.clone()), Self::BadRequest(msg) => (StatusCode::BAD_REQUEST, msg.clone()), Self::Forbidden(msg) => (StatusCode::FORBIDDEN, msg.clone()), + Self::Conflict(msg) => (StatusCode::CONFLICT, msg.clone()), Self::BannedUser { reason, csrf_token } => { let html = crate::templates::ban_page(reason, csrf_token); return (StatusCode::FORBIDDEN, Html(html)).into_response(); diff --git a/src/main.rs b/src/main.rs index f2609cf..b4ec61d 100644 --- a/src/main.rs +++ b/src/main.rs @@ -2,6 +2,7 @@ // // Run modes (via subcommands): // rustchan-cli → start the web server (default) +// rustchan-cli serve --chan-net → start server + ChanNet API listener // rustchan-cli admin create-admin

→ create an admin user // rustchan-cli admin reset-password

→ reset admin password // rustchan-cli admin list-admins → list admins @@ -18,9 +19,11 @@ // All HTTP server logic lives in server/server.rs. // CLI types and admin commands live in server/cli.rs. // Terminal console and startup banner live in server/console.rs. +// ChanNet / RustWave gateway lives in chan_net/mod.rs (second listener, port 7070). use clap::Parser; +mod chan_net; mod config; mod db; mod detect; @@ -79,10 +82,11 @@ fn main() -> anyhow::Result<()> { rt.block_on(async move { match cli.command { - None | Some(server::cli::Command::Serve { port: None }) => { - server::run_server(None).await + // Default (no subcommand) or explicit `serve`: start the server. + None | Some(server::cli::Command::Serve) => { + server::run_server(cli.port, cli.chan_net).await } - Some(server::cli::Command::Serve { port }) => server::run_server(port).await, + Some(server::cli::Command::Admin { action }) => { server::cli::run_admin(action)?; Ok(()) diff --git a/src/middleware/mod.rs b/src/middleware/mod.rs index a1f3cce..7eea105 100644 --- a/src/middleware/mod.rs +++ b/src/middleware/mod.rs @@ -130,6 +130,13 @@ pub struct AppState { pub job_queue: std::sync::Arc, /// Live backup progress counters. Polled by GET /admin/backup/progress. pub backup_progress: std::sync::Arc, + /// `ChanNet` transaction deduplication ledger (Step 1.5). + /// `Some(...)` only when the server was started with `--chan-net`. + /// Wraps a `HashSet` of already-imported snapshot transaction IDs. + /// The DB's unique index on `chan_net_posts` provides a persistent safety net + /// even if the ledger is cleared by a server restart. + pub chan_ledger: + Option>>>, } /// Get current Unix timestamp in seconds. diff --git a/src/models.rs b/src/models.rs index d3debe6..a037fd6 100644 --- a/src/models.rs +++ b/src/models.rs @@ -434,6 +434,51 @@ pub struct BanAppeal { pub created_at: i64, } +// ─── ChanNet federation snapshot types ─────────────────────────────────────── +// +// Defined here (not in chan_net::snapshot) so that src/db/chan_net.rs can +// reference SnapshotPost without creating a layering inversion. chan_net is +// declared in main.rs and is therefore not accessible from the lib crate; +// models.rs is re-exported by lib.rs and is safe to import from anywhere. +// +// chan_net::snapshot re-exports these types so that all existing call-sites +// (snapshot::SnapshotPost, etc.) continue to compile without change. + +/// A single board entry in a federation snapshot. +/// `id` is the board's `short_name` (e.g. "tech", "b"). +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SnapshotBoard { + pub id: String, + pub title: String, +} + +/// A single post in a federation snapshot. +/// +/// SECURITY: Text content only. File paths, MIME types, thumbnail paths, and +/// binary data must NEVER be added to this struct. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SnapshotPost { + pub post_id: u64, + pub board: String, + pub author: String, + pub content: String, + pub timestamp: u64, +} + +/// Metadata block written into every federation snapshot ZIP. +#[derive(Serialize, Deserialize, Debug, Clone)] +pub struct SnapshotMetadata { + pub generated_at: u64, + pub rustchan_version: String, + pub post_count: u64, + pub tx_id: uuid::Uuid, + pub signature: Option, + // Delta fields — always None / false in full federation snapshots. + pub since: Option, + pub is_delta: bool, + pub includes_archive: bool, +} + // ─── Tests ──────────────────────────────────────────────────────────────────── #[cfg(test)] diff --git a/src/server/cli.rs b/src/server/cli.rs index db91222..c97a830 100644 --- a/src/server/cli.rs +++ b/src/server/cli.rs @@ -17,16 +17,21 @@ use clap::{Parser, Subcommand}; Run without arguments to start the server." )] pub struct Cli { + /// TCP port to bind the main forum server + #[arg(long, short = 'p', global = true)] + pub port: Option, + + /// Enable the `ChanNet` / `RustWave` API on a second port (see `chan_net_bind` in config) + #[arg(long = "chan-net", global = true)] + pub chan_net: bool, + #[command(subcommand)] pub command: Option, } #[derive(Subcommand)] pub enum Command { - Serve { - #[arg(long, short = 'p')] - port: Option, - }, + Serve, Admin { #[command(subcommand)] action: AdminAction, diff --git a/src/server/server.rs b/src/server/server.rs index 585572c..44bd931 100644 --- a/src/server/server.rs +++ b/src/server/server.rs @@ -86,7 +86,7 @@ impl Drop for ScopedDecrement<'_> { #[allow(clippy::too_many_lines)] #[allow(clippy::arithmetic_side_effects)] -pub async fn run_server(port_override: Option) -> anyhow::Result<()> { +pub async fn run_server(port_override: Option, chan_net: bool) -> anyhow::Result<()> { let early_data_dir = { let exe = std::env::current_exe() .ok() @@ -240,6 +240,13 @@ pub async fn run_server(port_override: Option) -> anyhow::Result<()> { q }, backup_progress: std::sync::Arc::new(crate::middleware::BackupProgress::new()), + chan_ledger: if chan_net { + Some(std::sync::Arc::new(parking_lot::Mutex::new( + std::collections::HashSet::::new(), + ))) + } else { + None + }, }; // Keep a reference to the job queue cancel token for graceful shutdown (#7). let worker_cancel = state.job_queue.cancel.clone(); @@ -404,7 +411,7 @@ pub async fn run_server(port_override: Option) -> anyhow::Result<()> { }); } - let app = build_router(state); + let app = build_router(state.clone()); let listener = tokio::net::TcpListener::bind(&bind_addr).await?; info!("Listening on http://{bind_addr}"); info!("Admin panel http://{bind_addr}/admin"); @@ -413,6 +420,18 @@ pub async fn run_server(port_override: Option) -> anyhow::Result<()> { super::console::spawn_keyboard_handler(pool, start_time); + if chan_net { + let chan_addr = crate::config::CONFIG.chan_net_bind.clone(); + let chan_app = crate::chan_net::chan_router(state.clone()); + let chan_listener = tokio::net::TcpListener::bind(&chan_addr).await?; + tracing::info!("ChanNet API listening on http://{chan_addr}/chan/status"); + tokio::spawn(async move { + if let Err(e) = axum::serve(chan_listener, chan_app.into_make_service()).await { + tracing::error!("ChanNet server error: {e}"); + } + }); + } + axum::serve( listener, app.into_make_service_with_connect_info::(), From a653d4c128d97e2d3a35da69d3cdc0cd2cd1cd3f Mon Sep 17 00:00:00 2001 From: csd113 Date: Tue, 17 Mar 2026 12:35:33 -0700 Subject: [PATCH 2/2] api fix and clippy --- audit_report.md | 961 ++++++++++++++++++++++++++++++++++ clippy_reports/clippy_raw.txt | 0 clippy_reports/summary.txt | 5 + src/chan_net/command.rs | 52 +- src/chan_net/mod.rs | 10 +- src/db/mod.rs | 74 ++- 6 files changed, 1097 insertions(+), 5 deletions(-) create mode 100644 audit_report.md create mode 100644 clippy_reports/clippy_raw.txt create mode 100644 clippy_reports/summary.txt diff --git a/audit_report.md b/audit_report.md new file mode 100644 index 0000000..f4cb5d0 --- /dev/null +++ b/audit_report.md @@ -0,0 +1,961 @@ +# RustChan — Comprehensive Security & Code Audit Report + +**Audited:** `src/` (Rust imageboard server) +**Date:** 2026-03-17 +**Auditor:** Static analysis + manual review + +--- + +## Summary + +| Severity | Count | +|----------|-------| +| **Critical** | 3 | +| **High** | 8 | +| **Medium** | 12 | +| **Low** | 11 | +| **Total** | **34** | + +**Overall assessment:** The codebase demonstrates strong security awareness — parameterized queries throughout, Argon2id password hashing, constant-time CSRF comparison, EXIF stripping, path-traversal guards, and extensive inline documentation of past fixes. However, several real production risks remain, including an unverified federation layer, a process-management bug that makes the ffmpeg timeout deceptive, and stored XSS via SVG uploads. + +--- + +## Complete Issue Index + +| # | Severity | Title | File | +|---|----------|-------|------| +| 1 | **Critical** | SVG served inline — stored XSS | `handlers/board.rs:753` | +| 2 | **Critical** | ChanNet Ed25519 signatures not verified | `chan_net/import.rs:~90` | +| 3 | **Critical** | ffmpeg process not killed on timeout | `workers/mod.rs:460` | +| 4 | **High** | Swapped format args in mod log entry | `handlers/admin/moderation.rs:183` | +| 5 | **High** | Unbounded body on admin restore endpoints | `server/server.rs:598,606` | +| 6 | **High** | `edit_post` misuses `execute_batch` for transactions | `db/posts.rs:~190` | +| 7 | **High** | `insert_board_if_absent` uses `last_insert_rowid()` | `db/chan_net.rs:~57` | +| 8 | **High** | Catalog has no ETag caching | `handlers/board.rs:500` | +| 9 | **High** | ChanNet `/chan/refresh` and `/chan/poll` unauthenticated | `chan_net/mod.rs:146` | +| 10 | **High** | Worker `JoinHandle`s discarded; shutdown is blind sleep | `server/server.rs:239` | +| 11 | **High** | `get_per_board_stats` still uses correlated subqueries | `db/boards.rs:416` | +| 12 | **Medium** | PoW nonce prune not rate-gated under concurrency | `utils/crypto.rs:~165` | +| 13 | **Medium** | `constant_time_eq` in posts.rs — use `subtle` crate | `db/posts.rs:~270` | +| 14 | **Medium** | `update_settings_file_site_names` matches comment lines | `config.rs:~320` | +| 15 | **Medium** | `admin_ban_and_delete` ban+delete not transactional | `handlers/admin/moderation.rs:~160` | +| 16 | **Medium** | Poll expiry not re-checked inside `cast_vote` | `db/posts.rs:cast_vote` | +| 17 | **Medium** | `has_recent_appeal` TOCTOU — no schema constraint | `db/admin.rs` | +| 18 | **Medium** | Gateway `insert_reply_into_thread` stores unescaped HTML | `db/chan_net.rs:~130` | +| 19 | **Medium** | Admin restore doesn't validate SQLite magic bytes | `handlers/admin/backup.rs` | +| 20 | **Medium** | Rate-limit IP hash uses hardcoded salt `"G"` not `cookie_secret` | `middleware/mod.rs:~155` | +| 21 | **Medium** | `thread_updates` builds JSON by string interpolation | `handlers/thread.rs:~360` | +| 22 | **Medium** | `Content-Disposition` header injection via `board` field | `chan_net/command.rs:140` | +| 23 | **Medium** | `pool_size` hardcoded despite comment claiming config | `db/mod.rs:143` | +| 24 | **Medium** | `classify_upload_error` fragile string-prefix matching | `handlers/mod.rs` | +| 25 | **Low** | `edit_post` `edit_window_secs=0` semantic mismatch | `db/posts.rs:~200` | +| 26 | **Low** | `delete_file` silently ignores filesystem errors | `utils/files.rs` | +| 27 | **Low** | `sanitize_filename` truncates by char count, not bytes | `utils/sanitize.rs` | +| 28 | **Low** | Tripcode uses unsalted SHA-256 | `utils/tripcode.rs` | +| 29 | **Low** | `ffmpeg` encoder detection makes 3 separate subprocess calls | `media/ffmpeg.rs` | +| 30 | **Low** | `encode_q` duplicated twice in backup.rs | `handlers/admin/backup.rs` | +| 31 | **Low** | `collect_thread_file_paths` unbounded SQLite variable count | `db/threads.rs:~75` | +| 32 | **Low** | `prune_login_fails` uses `Ordering::Relaxed` store | `handlers/admin/auth.rs:~83` | +| 33 | **Low** | Log file never rotated — will grow unbounded | `logging.rs:~35` | +| 34 | **Low** | `ACTIVE_IPS` prune clears entire map on first run after start | `server/server.rs:~305` | + +--- + +## Detailed Findings + +--- + +### [Critical] #1 — SVG Served Inline Without `Content-Disposition: attachment` + +**File:** `src/handlers/board.rs:753` and `src/utils/files.rs` (`detect_mime_type`) + +**Problem:** +SVG files are accepted as uploads (`image/svg+xml` is explicitly allowed in `detect_mime_type`) and served back with `Content-Type: image/svg+xml` inline via the `media_content_type()` map. An SVG file can contain `