commit 125321c418395267161e7cd4400c5e9a4d8239194721f748dab58392f86bf1be Author: unknown Date: Fri May 22 02:52:15 2026 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..bd2bc7e --- /dev/null +++ b/.gitignore @@ -0,0 +1,91 @@ +config/default.toml +/data +/systemd +**/dist + +debug +target + +# These are backup files generated by rustfmt +**/*.rs.bk + +# MSVC Windows builds of rustc generate these, which store debugging information +*.pdb + +# Generated by cargo mutants +# Contains mutation testing data +**/mutants.out*/ + +# rustc will dump stack traces when hitting an internal compiler error to PWD +rustc-ice-*.txt + +master.key + +db.sqlite + +node_modules/ +.node_modules/ +built/* +tests/cases/rwc/* +tests/cases/perf/* +!tests/cases/webharness/compilerToString.js +test-args.txt +~*.docx +\#*\# +.\#* +tests/baselines/local/* +tests/baselines/local.old/* +tests/services/baselines/local/* +tests/baselines/prototyping/local/* +tests/baselines/rwc/* +tests/baselines/reference/projectOutput/* +tests/baselines/local/projectOutput/* +tests/baselines/reference/testresults.tap +tests/baselines/symlinks/* +tests/services/baselines/prototyping/local/* +tests/services/browser/typescriptServices.js +src/harness/*.js +src/compiler/diagnosticInformationMap.generated.ts +src/compiler/diagnosticMessages.generated.json +src/parser/diagnosticInformationMap.generated.ts +src/parser/diagnosticMessages.generated.json +rwc-report.html +*.swp +build.json +*.actual +tests/webTestServer.js +tests/webTestServer.js.map +tests/webhost/*.d.ts +tests/webhost/webtsc.js +tests/cases/**/*.js +tests/cases/**/*.js.map +*.config +scripts/eslint/built/ +scripts/debug.bat +scripts/run.bat +scripts/**/*.js +scripts/**/*.js.map +coverage/ +internal/ +**/.DS_Store +.settings +**/.vs +**/.vscode/* +!**/.vscode/tasks.json +!**/.vscode/settings.template.json +!**/.vscode/launch.template.json +!**/.vscode/extensions.json +!tests/cases/projects/projectOption/**/node_modules +!tests/cases/projects/NodeModulesSearch/**/* +!tests/baselines/reference/project/nodeModules*/**/* +.idea +yarn.lock +yarn-error.log +.parallelperf.* +tests/baselines/reference/dt +.failed-tests +TEST-results.xml +package-lock.json +.eslintcache +*v8.log +/lib/ \ No newline at end of file diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..d0b615a --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,3713 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 4 + +[[package]] +name = "adler2" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" + +[[package]] +name = "aes" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures 0.2.17", +] + +[[package]] +name = "aes-kw" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69fa2b352dcefb5f7f3a5fb840e02665d311d878955380515e4fd50095dd3d8c" +dependencies = [ + "aes", +] + +[[package]] +name = "ahash" +version = "0.8.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" +dependencies = [ + "cfg-if", + "once_cell", + "version_check", + "zerocopy", +] + +[[package]] +name = "aho-corasick" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd31a130427c27518df266943a5308ed92d4b226cc639f5a8f1002816174301" +dependencies = [ + "memchr", +] + +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + +[[package]] +name = "android_system_properties" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "819e7219dbd41043ac279b19830f2efc897156490d7fd6ea916720117ee66311" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.102" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" + +[[package]] +name = "aquamarine" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "21cc1548309245035eb18aa7f0967da6bc65587005170c56e6ef2788a4cf3f4e" +dependencies = [ + "include_dir", + "itertools", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures 0.2.17", + "password-hash", +] + +[[package]] +name = "arraydeque" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d902e3d592a523def97af8f317b08ce16b7ab854c1985a0c671e6f15cebc236" + +[[package]] +name = "arrayref" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76a2e8124351fda1ef8aaaa3bbd7ebbcb486bbcd4225aca0aa0d84bb2db8fecb" + +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + +[[package]] +name = "async-compression" +version = "0.4.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e79b3f8a79cccc2898f31920fc69f304859b3bd567490f75ebf51ae1c792a9ac" +dependencies = [ + "compression-codecs", + "compression-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "async-trait" +version = "0.1.89" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "atomic-waker" +version = "1.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" + +[[package]] +name = "autocfg" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" + +[[package]] +name = "axum" +version = "0.7.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "edca88bc138befd0323b20752846e6587272d3b03b0343c8ea28a6f819e6e71f" +dependencies = [ + "async-trait", + "axum-core", + "axum-macros", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "hyper 1.9.0", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "rustversion", + "serde", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper 1.0.2", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09f2bd6146b97ae3359fa0cc6d6b376d9539582c7b4220f041a33ec24c226199" +dependencies = [ + "async-trait", + "bytes", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "mime", + "pin-project-lite", + "rustversion", + "sync_wrapper 1.0.2", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-macros" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "57d123550fa8d071b7255cb0cc04dc302baa6c8c4a79f55701552684d8399bce" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "base64" +version = "0.21.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d297deb1925b89f2ccc13d7635fa0714f12c87adce1c75356b39ca9b7178567" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitflags" +version = "2.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[package]] +name = "blake3" +version = "1.8.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0aa83c34e62843d924f905e0f5c866eb1dd6545fc4d719e803d9ba6030371fce" +dependencies = [ + "arrayref", + "arrayvec", + "cc", + "cfg-if", + "constant_time_eq", + "cpufeatures 0.3.0", +] + +[[package]] +name = "block-buffer" +version = "0.10.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3078c7629b62d3f0439517fa394996acacc5cbc91c5a20d8c658e77abd503a71" +dependencies = [ + "generic-array", +] + +[[package]] +name = "bumpalo" +version = "3.20.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d20789868f4b01b2f2caec9f5c4e0213b41e3e5702a50157d699ae31ced2fcb" + +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + +[[package]] +name = "bytes" +version = "1.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e748733b7cbc798e1434b6ac524f0c1ff2ab456fe201501e6497c8417a4fc33" + +[[package]] +name = "cc" +version = "1.2.62" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfb" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d38f2da7a0a2c4ccf0065be06397cc26a81f4e528be095826eee9d4adbb8c60f" +dependencies = [ + "byteorder", + "fnv", + "uuid", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "cgcx-bot" +version = "0.1.0" +dependencies = [ + "argon2", + "cgcx-config", + "cgcx-core", + "cgcx-crypto", + "cgcx-db", + "cgcx-file-pipeline", + "cgcx-moderation", + "cgcx-storage", + "chrono", + "fs2", + "hmac", + "password-hash", + "rand 0.8.6", + "regex", + "rusqlite", + "serde", + "serde_json", + "sha2", + "teloxide", + "tokio", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cgcx-config" +version = "0.1.0" +dependencies = [ + "cgcx-core", + "config", + "serde", + "tokio", + "tracing", +] + +[[package]] +name = "cgcx-content-typing" +version = "0.1.0" +dependencies = [ + "cgcx-core", + "infer", + "mime_guess", +] + +[[package]] +name = "cgcx-core" +version = "0.1.0" +dependencies = [ + "chrono", + "rand 0.8.6", + "serde", + "thiserror 1.0.69", +] + +[[package]] +name = "cgcx-crypto" +version = "0.1.0" +dependencies = [ + "aes", + "aes-kw", + "blake3", + "cgcx-core", + "hex", + "rand 0.8.6", + "sodiumoxide", + "tracing", +] + +[[package]] +name = "cgcx-db" +version = "0.1.0" +dependencies = [ + "cgcx-config", + "cgcx-core", + "chrono", + "rusqlite", + "rusqlite_migration", + "tokio", + "tracing", +] + +[[package]] +name = "cgcx-file-pipeline" +version = "0.1.0" +dependencies = [ + "cgcx-config", + "cgcx-content-typing", + "cgcx-core", + "cgcx-crypto", + "cgcx-db", + "cgcx-storage", + "chrono", + "sodiumoxide", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "cgcx-moderation" +version = "0.1.0" +dependencies = [ + "cgcx-config", + "cgcx-core", + "chrono", + "serde", + "serde_json", + "tokio", + "tracing", +] + +[[package]] +name = "cgcx-server" +version = "0.1.0" +dependencies = [ + "argon2", + "axum", + "base64", + "blake3", + "cgcx-config", + "cgcx-content-typing", + "cgcx-core", + "cgcx-crypto", + "cgcx-db", + "cgcx-file-pipeline", + "cgcx-moderation", + "cgcx-storage", + "chrono", + "hex", + "hmac", + "password-hash", + "serde", + "serde_json", + "sha2", + "sodiumoxide", + "subtle", + "tokio", + "tokio-stream", + "tower", + "tower-http", + "tower_governor", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "cgcx-storage" +version = "0.1.0" +dependencies = [ + "cgcx-config", + "cgcx-core", + "tempfile", + "tokio", + "tracing", +] + +[[package]] +name = "chrono" +version = "0.4.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c673075a2e0e5f4a1dde27ce9dee1ea4558c7ffe648f576438a20ca1d2acc4b0" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", +] + +[[package]] +name = "compression-codecs" +version = "0.4.38" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce2548391e9c1929c21bf6aa2680af86fe4c1b33e6cea9ac1cfeec0bd11218cf" +dependencies = [ + "compression-core", + "flate2", + "memchr", +] + +[[package]] +name = "compression-core" +version = "0.4.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc14f565cf027a105f7a44ccf9e5b424348421a1d8952a8fc9d499d313107789" + +[[package]] +name = "config" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68578f196d2a33ff61b27fae256c3164f65e36382648e30666dde05b8cc9dfdf" +dependencies = [ + "async-trait", + "convert_case 0.6.0", + "json5", + "nom", + "pathdiff", + "ron", + "rust-ini", + "serde", + "serde_json", + "toml", + "yaml-rust2", +] + +[[package]] +name = "const-random" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "87e00182fe74b066627d63b85fd550ac2998d4b0bd86bfed477a0ae4c7c71359" +dependencies = [ + "const-random-macro", +] + +[[package]] +name = "const-random-macro" +version = "0.1.16" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9d839f2a20b0aee515dc581a6172f2321f96cab76c1a38a4c584a194955390e" +dependencies = [ + "getrandom 0.2.17", + "once_cell", + "tiny-keccak", +] + +[[package]] +name = "constant_time_eq" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" + +[[package]] +name = "convert_case" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" + +[[package]] +name = "convert_case" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" +dependencies = [ + "unicode-segmentation", +] + +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b2a6cd9ae233e7f62ba4e9353e81a88df7fc8a5987b8d445b4d90c879bd156f6" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "core-foundation-sys" +version = "0.8.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" + +[[package]] +name = "cpufeatures" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "59ed5838eebb26a2bb2e58f6d5b5316989ae9d08bab10e0e6d103e656d1b0280" +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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9481c1c90cbf2ac953f07c8d4a58aa3945c425b7185c9154d67a65e4230da511" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "crossbeam-utils" +version = "0.8.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" + +[[package]] +name = "crunchy" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "460fbee9c2c2f33933d720630a6a0bac33ba7053db5344fac858d4b8952d77d5" + +[[package]] +name = "crypto-common" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" +dependencies = [ + "generic-array", + "typenum", +] + +[[package]] +name = "darling" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a01d95850c592940db9b8194bc39f4bc0e89dee5c4265e4b1807c34a9aba453c" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "859d65a907b6852c9361e3185c862aae7fafd2887876799fa55f5f99dc40d610" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 1.0.109", +] + +[[package]] +name = "darling_macro" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c972679f83bdf9c42bd905396b6c3588a843a17f0f16dfcfa3e2c5d57441835" +dependencies = [ + "darling_core", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "dashmap" +version = "6.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6361d5c062261c78a176addb82d4c821ae42bed6089de0e12603cd25de2059c" +dependencies = [ + "cfg-if", + "crossbeam-utils", + "hashbrown 0.14.5", + "lock_api", + "once_cell", + "parking_lot_core", +] + +[[package]] +name = "derive_more" +version = "0.99.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6edb4b64a43d977b8e99788fe3a04d483834fba1215a7e02caa415b626497f7f" +dependencies = [ + "convert_case 0.4.0", + "proc-macro2", + "quote", + "rustc_version", + "syn 2.0.117", +] + +[[package]] +name = "digest" +version = "0.10.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" +dependencies = [ + "block-buffer", + "crypto-common", + "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 2.0.117", +] + +[[package]] +name = "dlv-list" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "442039f5147480ba31067cb00ada1adae6892028e40e45fc5de7b7df6dcc1b5f" +dependencies = [ + "const-random", +] + +[[package]] +name = "dptree" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d81175dab5ec79c30e0576df2ed2c244e1721720c302000bb321b107e82e265c" +dependencies = [ + "futures", +] + +[[package]] +name = "ed25519" +version = "1.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91cff35c70bba8a626e3185d8cd48cc11b5437e1a5bcd15b9b5fa3c64b6dfee7" +dependencies = [ + "signature", +] + +[[package]] +name = "either" +version = "1.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" + +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "erasable" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "437cfb75878119ed8265685c41a115724eae43fb7cc5a0bf0e4ecc3b803af1c4" +dependencies = [ + "autocfg", + "scopeguard", +] + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "fallible-iterator" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2acce4a10f12dc2fb14a218589d4f1f62ef011b2d0cc4b3cb1bba8e94da14649" + +[[package]] +name = "fallible-streaming-iterator" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7360491ce676a36bf9bb3c56c1aa791658183a54d2744120f27285738d90465a" + +[[package]] +name = "fastrand" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" + +[[package]] +name = "find-msvc-tools" +version = "0.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" + +[[package]] +name = "flate2" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "843fba2746e448b37e26a819579957415c8cef339bf08564fe8b7ddbd959573c" +dependencies = [ + "crc32fast", + "miniz_oxide", +] + +[[package]] +name = "fnv" +version = "1.0.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1" + +[[package]] +name = "foldhash" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" + +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared", +] + +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + +[[package]] +name = "form_urlencoded" +version = "1.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb4cb245038516f5f85277875cdaa4f7d2c9a0fa0468de06ed190163b1581fcf" +dependencies = [ + "percent-encoding", +] + +[[package]] +name = "forwarded-header-value" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8835f84f38484cc86f110a805655697908257fb9a7af005234060891557198e9" +dependencies = [ + "nonempty", + "thiserror 1.0.69", +] + +[[package]] +name = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b147ee9d1f6d097cef9ce628cd2ee62288d963e16fb287bd9286455b241382d" +dependencies = [ + "futures-channel", + "futures-core", + "futures-executor", + "futures-io", + "futures-sink", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", + "futures-sink", +] + +[[package]] +name = "futures-core" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" + +[[package]] +name = "futures-executor" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "baf29c38818342a3b26b5b923639e7b1f4a61fc5e76102d4b1981c6dc7a7579d" +dependencies = [ + "futures-core", + "futures-task", + "futures-util", +] + +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-macro" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "futures-sink" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c39754e157331b013978ec91992bde1ac089843443c49cbc7f46150b0fad0893" + +[[package]] +name = "futures-task" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "037711b3d59c33004d3856fbdc83b99d4ff37a24768fa1be9ce3538a1cde4393" + +[[package]] +name = "futures-timer" +version = "3.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af43fadb8a98512d547e37b4e92e0ced13e205c061b87b4623eff01d918d6968" + +[[package]] +name = "futures-util" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "389ca41296e6190b48053de0321d02a77f32f8a5d2461dd38762c0593805c6d6" +dependencies = [ + "futures-channel", + "futures-core", + "futures-io", + "futures-macro", + "futures-sink", + "futures-task", + "memchr", + "pin-project-lite", + "slab", +] + +[[package]] +name = "generic-array" +version = "0.14.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a" +dependencies = [ + "typenum", + "version_check", +] + +[[package]] +name = "getrandom" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" +dependencies = [ + "cfg-if", + "libc", + "wasi", +] + +[[package]] +name = "getrandom" +version = "0.3.4" +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]] +name = "getrandom" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", + "wasip2", + "wasip3", +] + +[[package]] +name = "governor" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be93b4ec2e4710b04d9264c0c7350cdd62a8c20e5e4ac732552ebb8f0debe8eb" +dependencies = [ + "cfg-if", + "dashmap", + "futures-sink", + "futures-timer", + "futures-util", + "getrandom 0.3.4", + "no-std-compat", + "nonzero_ext", + "parking_lot", + "portable-atomic", + "quanta", + "rand 0.9.4", + "smallvec", + "spinning_top", + "web-time", +] + +[[package]] +name = "h2" +version = "0.3.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0beca50380b1fc32983fc1cb4587bfa4bb9e78fc259aad4a0032d2080309222d" +dependencies = [ + "bytes", + "fnv", + "futures-core", + "futures-sink", + "futures-util", + "http 0.2.12", + "indexmap", + "slab", + "tokio", + "tokio-util", + "tracing", +] + +[[package]] +name = "hashbrown" +version = "0.14.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5274423e17b7c9fc20b6e7e208532f9b19825d82dfd615708b70edd83df41f1" +dependencies = [ + "ahash", + "allocator-api2", +] + +[[package]] +name = "hashbrown" +version = "0.15.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" +dependencies = [ + "foldhash", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "hashlink" +version = "0.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e8094feaf31ff591f651a2664fb9cfd92bba7a60ce3197265e9482ebe753c8f7" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "hashlink" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ba4ff7128dee98c7dc9794b6a411377e1404dba1c97deb8d1a55297bd25d8af" +dependencies = [ + "hashbrown 0.14.5", +] + +[[package]] +name = "heck" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95505c38b4572b2d910cecb0281560f54b440a19336cbbcb27bf6ce6adc6f5a8" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "hex" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + +[[package]] +name = "http" +version = "0.2.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "601cbb57e577e2f5ef5be8e7b83f0f63994f25aa94d673e54a92d5c516d101f1" +dependencies = [ + "bytes", + "fnv", + "itoa", +] + +[[package]] +name = "http" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3ba2a386d7f85a81f119ad7498ebe444d2e22c2af0b86b069416ace48b3311a" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ceab25649e9960c0311ea418d17bee82c0dcec1bd053b5f9a66e265a693bed2" +dependencies = [ + "bytes", + "http 0.2.12", + "pin-project-lite", +] + +[[package]] +name = "http-body" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1efedce1fb8e6913f23e0c92de8e62cd5b772a67e7b3946df930a62566c93184" +dependencies = [ + "bytes", + "http 1.4.0", +] + +[[package]] +name = "http-body-util" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b021d93e26becf5dc7e1b75b1bed1fd93124b374ceb73f43d4d4eafec896a64a" +dependencies = [ + "bytes", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "pin-project-lite", +] + +[[package]] +name = "http-range-header" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c" + +[[package]] +name = "httparse" +version = "1.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" + +[[package]] +name = "httpdate" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" + +[[package]] +name = "hyper" +version = "0.14.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41dfc780fdec9373c01bae43289ea34c972e40ee3c9f6b3c8801a35f35586ce7" +dependencies = [ + "bytes", + "futures-channel", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "socket2 0.5.10", + "tokio", + "tower-service", + "tracing", + "want", +] + +[[package]] +name = "hyper" +version = "1.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" +dependencies = [ + "atomic-waker", + "bytes", + "futures-channel", + "futures-core", + "http 1.4.0", + "http-body 1.0.1", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-tls" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6183ddfa99b85da61a140bea0efc93fdf56ceaa041b37d553518030827f9905" +dependencies = [ + "bytes", + "hyper 0.14.32", + "native-tls", + "tokio", + "tokio-native-tls", +] + +[[package]] +name = "hyper-util" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96547c2556ec9d12fb1578c4eaf448b04993e7fb79cbaad930a656880a6bdfa0" +dependencies = [ + "bytes", + "http 1.4.0", + "http-body 1.0.1", + "hyper 1.9.0", + "pin-project-lite", + "tokio", + "tower-service", +] + +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + +[[package]] +name = "icu_collections" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2984d1cd16c883d7935b9e07e44071dca8d917fd52ecc02c04d5fa0b5a3f191c" +dependencies = [ + "displaydoc", + "potential_utf", + "utf8_iter", + "yoke", + "zerofrom", + "zerovec", +] + +[[package]] +name = "icu_locale_core" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92219b62b3e2b4d88ac5119f8904c10f8f61bf7e95b640d25ba3075e6cac2c29" +dependencies = [ + "displaydoc", + "litemap", + "tinystr", + "writeable", + "zerovec", +] + +[[package]] +name = "icu_normalizer" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c56e5ee99d6e3d33bd91c5d85458b6005a22140021cc324cea84dd0e72cff3b4" +dependencies = [ + "icu_collections", + "icu_normalizer_data", + "icu_properties", + "icu_provider", + "smallvec", + "zerovec", +] + +[[package]] +name = "icu_normalizer_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da3be0ae77ea334f4da67c12f149704f19f81d1adf7c51cf482943e84a2bad38" + +[[package]] +name = "icu_properties" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bee3b67d0ea5c2cca5003417989af8996f8604e34fb9ddf96208a033901e70de" +dependencies = [ + "icu_collections", + "icu_locale_core", + "icu_properties_data", + "icu_provider", + "zerotrie", + "zerovec", +] + +[[package]] +name = "icu_properties_data" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e2bbb201e0c04f7b4b3e14382af113e17ba4f63e2c9d2ee626b720cbce54a14" + +[[package]] +name = "icu_provider" +version = "2.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "139c4cf31c8b5f33d7e199446eff9c1e02decfc2f0eec2c8d71f65befa45b421" +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 = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[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.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb68373c0d6620ef8105e855e7745e18b0d00d3bdb07fb532e434244cdb9a714" +dependencies = [ + "icu_normalizer", + "icu_properties", +] + +[[package]] +name = "include_dir" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd" +dependencies = [ + "include_dir_macros", +] + +[[package]] +name = "include_dir_macros" +version = "0.7.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75" +dependencies = [ + "proc-macro2", + "quote", +] + +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown 0.17.1", + "serde", + "serde_core", +] + +[[package]] +name = "infer" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc150e5ce2330295b8616ce0e3f53250e53af31759a9dbedad1621ba29151847" +dependencies = [ + "cfb", +] + +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "generic-array", +] + +[[package]] +name = "ipnet" +version = "2.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" + +[[package]] +name = "itertools" +version = "0.10.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" +dependencies = [ + "either", +] + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "js-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" +dependencies = [ + "cfg-if", + "futures-util", + "once_cell", + "wasm-bindgen", +] + +[[package]] +name = "json5" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96b0db21af676c1ce64250b5f40f3ce2cf27e4e47cb91ed91eb6fe9350b430c1" +dependencies = [ + "pest", + "pest_derive", + "serde", +] + +[[package]] +name = "lazy_static" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe" + +[[package]] +name = "leb128fmt" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09edd9e8b54e49e587e4f6295a7d29c3ea94d469cb40ab8ca70b288248a81db2" + +[[package]] +name = "libc" +version = "0.2.186" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" + +[[package]] +name = "libsodium-sys" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6b779387cd56adfbc02ea4a668e704f729be8d6a6abd2c27ca5ee537849a92fd" +dependencies = [ + "cc", + "libc", + "pkg-config", + "walkdir", +] + +[[package]] +name = "libsqlite3-sys" +version = "0.30.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e99fb7a497b1e3339bc746195567ed8d3e24945ecd636e3619d20b9de9e9149" +dependencies = [ + "cc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[package]] +name = "litemap" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92daf443525c4cce67b150400bc2316076100ce0b3686209eb8cf3c31612e6f0" + +[[package]] +name = "lock_api" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "224399e74b87b5f3557511d98dff8b14089b3dadafcab6bb93eab67d3aace965" +dependencies = [ + "scopeguard", +] + +[[package]] +name = "log" +version = "0.4.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897" + +[[package]] +name = "matchers" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9" +dependencies = [ + "regex-automata", +] + +[[package]] +name = "matchit" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0e7465ac9959cc2b1404e8e2367b43684a6d13790fe23056cc8c6c5a6b7bcb94" + +[[package]] +name = "memchr" +version = "2.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ca58f447f06ed17d5fc4043ce1b10dd205e060fb3ce5b979b8ed8e59ff3f79" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + +[[package]] +name = "minimal-lexical" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" + +[[package]] +name = "miniz_oxide" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fa76a2c86f704bdb222d66965fb3d63269ce38518b83cb0575fca855ebb6316" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "50b7e5b27aa02a74bac8c3f23f448f8d87ff11f92d3aac1a6ed369ee08cc56c1" +dependencies = [ + "libc", + "wasi", + "windows-sys 0.61.2", +] + +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + +[[package]] +name = "no-std-compat" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b93853da6d84c2e3c7d730d6473e8817692dd89be387eb01b94d7f108ecb5b8c" + +[[package]] +name = "nom" +version = "7.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d273983c5a657a70a3e8f2a01329822f3b8c8172b73826411a55751e404a0a4a" +dependencies = [ + "memchr", + "minimal-lexical", +] + +[[package]] +name = "nonempty" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9e591e719385e6ebaeb5ce5d3887f7d5676fceca6411d1925ccc95745f3d6f7" + +[[package]] +name = "nonzero_ext" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38bf9645c8b145698bb0b18a4637dcacbc421ea49bef2317e4fd8065a387cf21" + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "num-traits" +version = "0.2.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "071dfc062690e90b734c0b2273ce72ad0ffa95f0c74596bc250dcfd960262841" +dependencies = [ + "autocfg", +] + +[[package]] +name = "once_cell" +version = "1.21.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9f7c3e4beb33f85d45ae3e3a1792185706c8e16d043238c593331cc7cd313b50" + +[[package]] +name = "openssl" +version = "0.10.80" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a45fa2aa886c42762255da344f0a0d313e254066c46aad76f300c3d3da62d967" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "foreign-types", + "libc", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.116" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f28a22dc7140cda5f096e5e7724a6962ca81a7f8bfd2979f9b18c11af56318c4" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + +[[package]] +name = "ordered-multimap" +version = "0.7.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49203cdcae0030493bad186b28da2fa25645fa276a51b6fec8010d281e02ef79" +dependencies = [ + "dlv-list", + "hashbrown 0.14.5", +] + +[[package]] +name = "parking_lot" +version = "0.12.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93857453250e3077bd71ff98b6a65ea6621a19bb0f559a85248955ac12c45a1a" +dependencies = [ + "lock_api", + "parking_lot_core", +] + +[[package]] +name = "parking_lot_core" +version = "0.9.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" +dependencies = [ + "cfg-if", + "libc", + "redox_syscall", + "smallvec", + "windows-link", +] + +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + +[[package]] +name = "pathdiff" +version = "0.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pest" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0848c601009d37dfa3430c4666e147e49cdcf1b92ecd3e63657d8a5f19da662" +dependencies = [ + "memchr", + "ucd-trie", +] + +[[package]] +name = "pest_derive" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11f486f1ea21e6c10ed15d5a7c77165d0ee443402f0780849d1768e7d9d6fe77" +dependencies = [ + "pest", + "pest_generator", +] + +[[package]] +name = "pest_generator" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8040c4647b13b210a963c1ed407c1ff4fdfa01c31d6d2a098218702e6664f94f" +dependencies = [ + "pest", + "pest_meta", + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pest_meta" +version = "2.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "89815c69d36021a140146f26659a81d6c2afa33d216d736dd4be5381a7362220" +dependencies = [ + "pest", + "sha2", +] + +[[package]] +name = "pin-project" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2466b2336ed02bcdca6b294417127b90ec92038d1d5c4fbeac971a922e0e0924" +dependencies = [ + "pin-project-internal", +] + +[[package]] +name = "pin-project-internal" +version = "1.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96395f0a926bc13b1c17622aaddda1ecb55d49c8f1bf9777e4d877800a43f8b" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "pkg-config" +version = "0.3.33" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" + +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "potential_utf" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0103b1cef7ec0cf76490e969665504990193874ea05c85ff9bab8b911d0a0564" +dependencies = [ + "zerovec", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "prettyplease" +version = "0.2.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" +dependencies = [ + "proc-macro2", + "syn 2.0.117", +] + +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + +[[package]] +name = "proc-macro2" +version = "1.0.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fd00f0bb2e90d81d1044c2b32617f68fcb9fa3bb7640c23e9c748e53fb30934" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quanta" +version = "0.12.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f3ab5a9d756f0d97bdc89019bd2e4ea098cf9cde50ee7564dde6b81ccc8f06c7" +dependencies = [ + "crossbeam-utils", + "libc", + "once_cell", + "raw-cpuid", + "wasi", + "web-sys", + "winapi", +] + +[[package]] +name = "quote" +version = "1.0.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41f2619966050689382d2b44f664f4bc593e129785a36d6ee376ddf37259b924" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "r-efi" +version = "5.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" + +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + +[[package]] +name = "rand" +version = "0.8.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca0ecfa931c29007047d1bc58e623ab12e5590e8c7cc53200d5202b69266d8a" +dependencies = [ + "libc", + "rand_chacha 0.3.1", + "rand_core 0.6.4", +] + +[[package]] +name = "rand" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "44c5af06bb1b7d3216d91932aed5265164bf384dc89cd6ba05cf59a35f5f76ea" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_chacha" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" +dependencies = [ + "ppv-lite86", + "rand_core 0.9.5", +] + +[[package]] +name = "rand_core" +version = "0.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ec0be4795e2f6a28069bec0b5ff3e2ac9bafc99e6a9a7dc3547996c5c816922c" +dependencies = [ + "getrandom 0.2.17", +] + +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + +[[package]] +name = "raw-cpuid" +version = "11.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "498cd0dc59d73224351ee52a95fee0f1a617a2eae0e7d9d720cc622c73a54186" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "rc-box" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "897fecc9fac6febd4408f9e935e86df739b0023b625e610e0357535b9c8adad0" +dependencies = [ + "erasable", +] + +[[package]] +name = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags 2.11.1", +] + +[[package]] +name = "regex" +version = "1.12.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e10754a14b9137dd7b1e3e5b0493cc9171fdd105e0ab477f51b72e7f3ac0e276" +dependencies = [ + "aho-corasick", + "memchr", + "regex-automata", + "regex-syntax", +] + +[[package]] +name = "regex-automata" +version = "0.4.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e1dd4122fc1595e8162618945476892eefca7b88c52820e74af6262213cae8f" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" + +[[package]] +name = "reqwest" +version = "0.11.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dd67538700a17451e7cba03ac727fb961abb7607553461627b97de0b89cf4a62" +dependencies = [ + "base64", + "bytes", + "encoding_rs", + "futures-core", + "futures-util", + "h2", + "http 0.2.12", + "http-body 0.4.6", + "hyper 0.14.32", + "hyper-tls", + "ipnet", + "js-sys", + "log", + "mime", + "mime_guess", + "native-tls", + "once_cell", + "percent-encoding", + "pin-project-lite", + "rustls-pemfile", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper 0.1.2", + "system-configuration", + "tokio", + "tokio-native-tls", + "tokio-util", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "wasm-streams", + "web-sys", + "winreg", +] + +[[package]] +name = "ron" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b91f7eff05f748767f183df4320a63d6936e9c6107d97c9e6bdd9784f4289c94" +dependencies = [ + "base64", + "bitflags 2.11.1", + "serde", + "serde_derive", +] + +[[package]] +name = "rusqlite" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7753b721174eb8ff87a9a0e799e2d7bc3749323e773db92e0984debb00019d6e" +dependencies = [ + "bitflags 2.11.1", + "chrono", + "fallible-iterator", + "fallible-streaming-iterator", + "hashlink 0.9.1", + "libsqlite3-sys", + "smallvec", +] + +[[package]] +name = "rusqlite_migration" +version = "1.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "923b42e802f7dc20a0a6b5e097ba7c83fe4289da07e49156fecf6af08aa9cd1c" +dependencies = [ + "log", + "rusqlite", +] + +[[package]] +name = "rust-ini" +version = "0.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0698206bcb8882bf2a9ecb4c1e7785db57ff052297085a6efd4fe42302068a" +dependencies = [ + "cfg-if", + "ordered-multimap", +] + +[[package]] +name = "rustc_version" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfcb3a22ef46e85b45de6ee7e79d063319ebb6594faafcf1c225ea92ab6e9b92" +dependencies = [ + "semver", +] + +[[package]] +name = "rustix" +version = "1.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" +dependencies = [ + "bitflags 2.11.1", + "errno", + "libc", + "linux-raw-sys", + "windows-sys 0.61.2", +] + +[[package]] +name = "rustls-pemfile" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1c74cae0a4cf6ccbbf5f359f08efdf8ee7e1dc532573bf0db71968cb56b1448c" +dependencies = [ + "base64", +] + +[[package]] +name = "rustversion" +version = "1.0.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "same-file" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "93fc1dc3aaa9bfed95e02e6eadabb4baf7e3078b0bd1b4d7b6b0b68378900502" +dependencies = [ + "winapi-util", +] + +[[package]] +name = "schannel" +version = "0.1.29" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91c1b7e4904c873ef0710c1f407dde2e6287de2bebc1bbbf7d430bb7cbffd939" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.1", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "semver" +version = "1.0.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7852d02fc848982e0c167ef163aaff9cd91dc640ba85e263cb1ce46fae51cd" + +[[package]] +name = "serde" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a8e94ea7f378bd32cbbd37198a4a91436180c5bb472411e48b5ec2e2124ae9e" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41d385c7d4ca58e59fc732af25c3983b67ac852c1a25000afe1175de458b67ad" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.228" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "serde_json" +version = "1.0.149" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" +dependencies = [ + "itoa", + "memchr", + "serde", + "serde_core", + "zmij", +] + +[[package]] +name = "serde_path_to_error" +version = "0.1.20" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10a9ff822e371bb5403e391ecd83e182e0e77ba7f6fe0160b795797109d1b457" +dependencies = [ + "itoa", + "serde", + "serde_core", +] + +[[package]] +name = "serde_spanned" +version = "0.6.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf41e0cfaf7226dca15e8197172c295a782857fcb97fad1808a166870dee75a3" +dependencies = [ + "serde", +] + +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + +[[package]] +name = "serde_with" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "678b5a069e50bf00ecd22d0cd8ddf7c236f68581b03db652061ed5eb13a312ff" +dependencies = [ + "serde", + "serde_with_macros", +] + +[[package]] +name = "serde_with_macros" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e182d6ec6f05393cc0e5ed1bf81ad6db3a8feedf8ee515ecdd369809bcce8082" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "sha2" +version = "0.10.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" +dependencies = [ + "cfg-if", + "cpufeatures 0.2.17", + "digest", +] + +[[package]] +name = "sharded-slab" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6" +dependencies = [ + "lazy_static", +] + +[[package]] +name = "shlex" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0fda2ff0d084019ba4d7c6f371c95d8fd75ce3524c3cb8fb653a3023f6323e64" + +[[package]] +name = "signal-hook-registry" +version = "1.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4db69cba1110affc0e9f7bcd48bbf87b3f4fc7c61fc9155afd4c469eb3d6c1b" +dependencies = [ + "errno", + "libc", +] + +[[package]] +name = "signature" +version = "1.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "74233d3b3b2f6d4b006dc19dee745e73e2a6bfb6f93607cd3b02bd5b00797d7c" + +[[package]] +name = "simd-adler32" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "703d5c7ef118737c72f1af64ad2f6f8c5e1921f818cdcb97b8fe6fc69bf66214" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.15.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" + +[[package]] +name = "socket2" +version = "0.5.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" +dependencies = [ + "libc", + "windows-sys 0.52.0", +] + +[[package]] +name = "socket2" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + +[[package]] +name = "sodiumoxide" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e26be3acb6c2d9a7aac28482586a7856436af4cfe7100031d219de2d2ecb0028" +dependencies = [ + "ed25519", + "libc", + "libsodium-sys", + "serde", +] + +[[package]] +name = "spinning_top" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d96d2d1d716fb500937168cc09353ffdc7a012be8475ac7308e1bdf0e3923300" +dependencies = [ + "lock_api", +] + +[[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.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73473c0e59e6d5812c5dfe2a064a6444949f089e20eec9a2e5506596494e4623" + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "1.0.109" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b64191b275b66ffe2469e8af2c1cfe3bafa67b529ead792a6d0160888b4237" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "2.0.117" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e665b8803e7b1d2a727f4023456bbbbe74da67099c585258af0ad9c5013b9b99" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2047c6ded9c721764247e62cd3b03c09ffc529b2ba5b10ec482ae507a4a70160" + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "synstructure" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "system-configuration" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba3a3adc5c275d719af8cb4272ea1c4a6d668a777f37e115f6d11ddbc1c8e0e7" +dependencies = [ + "bitflags 1.3.2", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75fb188eb626b924683e3b95e3a48e63551fcfb51949de2f06a9d91dbee93c9" +dependencies = [ + "core-foundation-sys", + "libc", +] + +[[package]] +name = "take_mut" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f764005d11ee5f36500a149ace24e00e3da98b0158b3e2d53a7495660d3f4d60" + +[[package]] +name = "takecell" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20f34339676cdcab560c9a82300c4c2581f68b9369aedf0fae86f2ff9565ff3e" + +[[package]] +name = "teloxide" +version = "0.13.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5f79dd283eb21b90451c03fa7c7f83b9985130efb876b33bad89a2c208ccbc16" +dependencies = [ + "aquamarine", + "bytes", + "derive_more", + "dptree", + "either", + "futures", + "log", + "mime", + "pin-project", + "serde", + "serde_json", + "teloxide-core", + "teloxide-macros", + "thiserror 1.0.69", + "tokio", + "tokio-stream", + "tokio-util", + "url", +] + +[[package]] +name = "teloxide-core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9e1642a7ef10e7af63b8298c8d13c0f986d4fc646d42649ff060359607f62f69" +dependencies = [ + "bitflags 1.3.2", + "bytes", + "chrono", + "derive_more", + "either", + "futures", + "log", + "mime", + "once_cell", + "pin-project", + "rc-box", + "reqwest", + "serde", + "serde_json", + "serde_with", + "take_mut", + "takecell", + "thiserror 1.0.69", + "tokio", + "tokio-util", + "url", + "uuid", +] + +[[package]] +name = "teloxide-macros" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7e2d33d809c3e7161a9ab18bedddf98821245014f0a78fa4d2c9430b2ec018c1" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "syn 1.0.109", +] + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.2", + "once_cell", + "rustix", + "windows-sys 0.61.2", +] + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl 1.0.69", +] + +[[package]] +name = "thiserror" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4288b5bcbc7920c07a1149a35cf9590a2aa808e0bc1eafaade0b80947865fbc4" +dependencies = [ + "thiserror-impl 2.0.18", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thiserror-impl" +version = "2.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc4ee7f67670e9b64d05fa4253e753e016c6c95ff35b89b7941d6b856dec1d5" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "thread_local" +version = "1.1.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tiny-keccak" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c9d3793400a45f954c52e73d068316d76b6f4e36977e3fcebb13a2721e80237" +dependencies = [ + "crunchy", +] + +[[package]] +name = "tinystr" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8323304221c2a851516f22236c5722a72eaa19749016521d6dff0824447d96d" +dependencies = [ + "displaydoc", + "zerovec", +] + +[[package]] +name = "tokio" +version = "1.52.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" +dependencies = [ + "bytes", + "libc", + "mio", + "pin-project-lite", + "signal-hook-registry", + "socket2 0.6.3", + "tokio-macros", + "windows-sys 0.61.2", +] + +[[package]] +name = "tokio-macros" +version = "2.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "385a6cb71ab9ab790c5fe8d67f1645e6c450a7ce006a33de03daa956cf70a496" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-stream" +version = "0.1.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32da49809aab5c3bc678af03902d4ccddea2a87d028d86392a4b1560c6906c70" +dependencies = [ + "futures-core", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "tokio-util" +version = "0.7.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9ae9cec805b01e8fc3fd2fe289f89149a9b66dd16786abd8b19cfa7b48cb0098" +dependencies = [ + "bytes", + "futures-core", + "futures-sink", + "pin-project-lite", + "tokio", +] + +[[package]] +name = "toml" +version = "0.8.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc1beb996b9d83529a9e75c17a1686767d148d70663143c7854d8b4a09ced362" +dependencies = [ + "serde", + "serde_spanned", + "toml_datetime", + "toml_edit", +] + +[[package]] +name = "toml_datetime" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cddaf88f4fbc13c51aebbf5f8eceb5c7c5a9da2ac40a13519eb5b0a0e8f11c" +dependencies = [ + "serde", +] + +[[package]] +name = "toml_edit" +version = "0.22.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41fe8c660ae4257887cf66394862d21dbca4a6ddd26f04a3560410406a2f819a" +dependencies = [ + "indexmap", + "serde", + "serde_spanned", + "toml_datetime", + "toml_write", + "winnow", +] + +[[package]] +name = "toml_write" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5d99f8c9a7727884afe522e9bd5edbfc91a3312b36a77b5fb8926e4c31a41801" + +[[package]] +name = "tower" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebe5ef63511595f1344e2d5cfa636d973292adc0eec1f0ad45fae9f0851ab1d4" +dependencies = [ + "futures-core", + "futures-util", + "pin-project-lite", + "sync_wrapper 1.0.2", + "tokio", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-http" +version = "0.6.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4cfcf7e2740e6fc6d4d688b4ef00650406bb94adf4731e43c096c3a19fe40840" +dependencies = [ + "async-compression", + "bitflags 2.11.1", + "bytes", + "futures-core", + "futures-util", + "http 1.4.0", + "http-body 1.0.1", + "http-body-util", + "http-range-header", + "httpdate", + "mime", + "mime_guess", + "percent-encoding", + "pin-project-lite", + "tokio", + "tokio-util", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "tower-layer" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "121c2a6cda46980bb0fcd1647ffaf6cd3fc79a013de288782836f6df9c48780e" + +[[package]] +name = "tower-service" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8df9b6e13f2d32c91b9bd719c00d1958837bc7dec474d94952798cc8e69eeec3" + +[[package]] +name = "tower_governor" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe9ce4d13ec8b7228fa4003e31ea6cfe27b113c9ba51d323aeb4bbe394ef06fe" +dependencies = [ + "axum", + "forwarded-header-value", + "governor", + "http 1.4.0", + "pin-project", + "thiserror 2.0.18", + "tower", + "tracing", +] + +[[package]] +name = "tracing" +version = "0.1.44" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100" +dependencies = [ + "log", + "pin-project-lite", + "tracing-attributes", + "tracing-core", +] + +[[package]] +name = "tracing-attributes" +version = "0.1.31" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "tracing-core" +version = "0.1.36" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a" +dependencies = [ + "once_cell", + "valuable", +] + +[[package]] +name = "tracing-log" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3" +dependencies = [ + "log", + "once_cell", + "tracing-core", +] + +[[package]] +name = "tracing-subscriber" +version = "0.3.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319" +dependencies = [ + "matchers", + "nu-ansi-term", + "once_cell", + "regex-automata", + "sharded-slab", + "smallvec", + "thread_local", + "tracing", + "tracing-core", + "tracing-log", +] + +[[package]] +name = "try-lock" +version = "0.2.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" + +[[package]] +name = "typenum" +version = "1.20.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" + +[[package]] +name = "ucd-trie" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2896d95c02a80c6d6a5d6e953d479f5ddf2dfdb6a244441010e373ac0fb88971" + +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unicode-segmentation" +version = "1.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" + +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[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", + "serde_derive", +] + +[[package]] +name = "utf8_iter" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" + +[[package]] +name = "uuid" +version = "1.23.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" +dependencies = [ + "getrandom 0.4.2", + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "walkdir" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29790946404f91d9c5d06f9874efddea1dc06c5efe94541a7d6863108e3a5e4b" +dependencies = [ + "same-file", + "winapi-util", +] + +[[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" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasip2" +version = "1.0.3+wasi-0.2.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20064672db26d7cdc89c7798c48a0fdfac8213434a1186e5ef29fd560ae223d6" +dependencies = [ + "wit-bindgen 0.57.1", +] + +[[package]] +name = "wasip3" +version = "0.4.0+wasi-0.3.0-rc-2026-01-06" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5428f8bf88ea5ddc08faddef2ac4a67e390b88186c703ce6dbd955e1c145aca5" +dependencies = [ + "wit-bindgen 0.51.0", +] + +[[package]] +name = "wasm-bindgen" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-futures" +version = "0.4.71" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 2.0.117", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.121" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "wasm-encoder" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "990065f2fe63003fe337b932cfb5e3b80e0b4d0f5ff650e6985b1048f62c8319" +dependencies = [ + "leb128fmt", + "wasmparser", +] + +[[package]] +name = "wasm-metadata" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" +dependencies = [ + "anyhow", + "indexmap", + "wasm-encoder", + "wasmparser", +] + +[[package]] +name = "wasm-streams" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "15053d8d85c7eccdbefef60f06769760a563c7f0a9d6902a13d35c7800b0ad65" +dependencies = [ + "futures-util", + "js-sys", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]] +name = "wasmparser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" +dependencies = [ + "bitflags 2.11.1", + "hashbrown 0.15.5", + "indexmap", + "semver", +] + +[[package]] +name = "web-sys" +version = "0.3.98" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" +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 = "winapi" +version = "0.3.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5c839a674fcd7a98952e593242ea400abe93992746761e38641405d28b00f419" +dependencies = [ + "winapi-i686-pc-windows-gnu", + "winapi-x86_64-pc-windows-gnu", +] + +[[package]] +name = "winapi-i686-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac3b87c63620426dd9b991e5ce0329eff545bccbbb34f3be09ff6fb6ab51b7b6" + +[[package]] +name = "winapi-util" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" +dependencies = [ + "windows-sys 0.61.2", +] + +[[package]] +name = "winapi-x86_64-pc-windows-gnu" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "712e227841d057c1ee1cd2fb22fa7e5a5461ae8e48fa2ca79ec42cfc1931183f" + +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-sys" +version = "0.48.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "677d2418bec65e3338edb076e806bc1ec15693c5d0104683f2efe857f61056a9" +dependencies = [ + "windows-targets 0.48.5", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-targets" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9a2fa6e2155d7247be68c096456083145c183cbbbc2764150dda45a87197940c" +dependencies = [ + "windows_aarch64_gnullvm 0.48.5", + "windows_aarch64_msvc 0.48.5", + "windows_i686_gnu 0.48.5", + "windows_i686_msvc 0.48.5", + "windows_x86_64_gnu 0.48.5", + "windows_x86_64_gnullvm 0.48.5", + "windows_x86_64_msvc 0.48.5", +] + +[[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", + "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_aarch64_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2b38e32f0abccf9987a4e3079dfb67dcd799fb61361e53e2882c3cbaf0d905d8" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc35310971f3b2dbbf3f0690a219f40e2d9afcf64f9ab7cc1be722937c26b4bc" + +[[package]] +name = "windows_aarch64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "09ec2a7bb152e2252b53fa7803150007879548bc709c039df7627cabbd05d469" + +[[package]] +name = "windows_i686_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a75915e7def60c94dcef72200b9a8e58e5091744960da64ec734a6c6e9b3743e" + +[[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_gnullvm" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0eee52d38c090b3caa76c563b86c3a4bd71ef1a819287c19d586d7334ae8ed66" + +[[package]] +name = "windows_i686_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f55c233f70c4b27f66c523580f78f1004e8b5a8b659e05a4eb49d4166cca406" + +[[package]] +name = "windows_i686_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "240948bc05c5e7c6dabba28bf89d89ffce3e303022809e73deaefe4f6ec56c66" + +[[package]] +name = "windows_x86_64_gnu" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "53d40abd2583d23e4718fddf1ebec84dbff8381c07cae67ff7768bbf19c6718e" + +[[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_gnullvm" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b7b52767868a23d5bab768e390dc5f5c55825b6d30b86c844ff2dc7414044cc" + +[[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_msvc" +version = "0.48.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed94fce61571a4006852b7389a063ab983c02eb1bb37b47f8272ce92d06d9538" + +[[package]] +name = "windows_x86_64_msvc" +version = "0.52.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" + +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" +dependencies = [ + "memchr", +] + +[[package]] +name = "winreg" +version = "0.50.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "524e57b2c537c0f9b1e69f1965311ec12182b4122e45035b1508cd24d2adadb1" +dependencies = [ + "cfg-if", + "windows-sys 0.48.0", +] + +[[package]] +name = "wit-bindgen" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7249219f66ced02969388cf2bb044a09756a083d0fab1e566056b04d9fbcaa5" +dependencies = [ + "wit-bindgen-rust-macro", +] + +[[package]] +name = "wit-bindgen" +version = "0.57.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ebf944e87a7c253233ad6766e082e3cd714b5d03812acc24c318f549614536e" + +[[package]] +name = "wit-bindgen-core" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea61de684c3ea68cb082b7a88508a8b27fcc8b797d738bfc99a82facf1d752dc" +dependencies = [ + "anyhow", + "heck 0.5.0", + "wit-parser", +] + +[[package]] +name = "wit-bindgen-rust" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" +dependencies = [ + "anyhow", + "heck 0.5.0", + "indexmap", + "prettyplease", + "syn 2.0.117", + "wasm-metadata", + "wit-bindgen-core", + "wit-component", +] + +[[package]] +name = "wit-bindgen-rust-macro" +version = "0.51.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c0f9bfd77e6a48eccf51359e3ae77140a7f50b1e2ebfe62422d8afdaffab17a" +dependencies = [ + "anyhow", + "prettyplease", + "proc-macro2", + "quote", + "syn 2.0.117", + "wit-bindgen-core", + "wit-bindgen-rust", +] + +[[package]] +name = "wit-component" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" +dependencies = [ + "anyhow", + "bitflags 2.11.1", + "indexmap", + "log", + "serde", + "serde_derive", + "serde_json", + "wasm-encoder", + "wasm-metadata", + "wasmparser", + "wit-parser", +] + +[[package]] +name = "wit-parser" +version = "0.244.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" +dependencies = [ + "anyhow", + "id-arena", + "indexmap", + "log", + "semver", + "serde", + "serde_derive", + "serde_json", + "unicode-xid", + "wasmparser", +] + +[[package]] +name = "writeable" +version = "0.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ffae5123b2d3fc086436f8834ae3ab053a283cfac8fe0a0b8eaae044768a4c4" + +[[package]] +name = "yaml-rust2" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8902160c4e6f2fb145dbe9d6760a75e3c9522d8bf796ed7047c85919ac7115f8" +dependencies = [ + "arraydeque", + "encoding_rs", + "hashlink 0.8.4", +] + +[[package]] +name = "yoke" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "abe8c5fda708d9ca3df187cae8bfb9ceda00dd96231bed36e445a1a48e66f9ca" +dependencies = [ + "stable_deref_trait", + "yoke-derive", + "zerofrom", +] + +[[package]] +name = "yoke-derive" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "de844c262c8848816172cef550288e7dc6c7b7814b4ee56b3e1553f275f1858e" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerocopy" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.48" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zerofrom" +version = "0.1.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0ec05a11813ea801ff6d75110ad09cd0824ddba17dfe17128ea0d5f68e6c5272" +dependencies = [ + "zerofrom-derive", +] + +[[package]] +name = "zerofrom-derive" +version = "0.1.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11532158c46691caf0f2593ea8358fed6bbf68a0315e80aae9bd41fbade684a1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", + "synstructure", +] + +[[package]] +name = "zerotrie" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0f9152d31db0792fa83f70fb2f83148effb5c1f5b8c7686c3459e361d9bc20bf" +dependencies = [ + "displaydoc", + "yoke", + "zerofrom", +] + +[[package]] +name = "zerovec" +version = "0.11.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90f911cbc359ab6af17377d242225f4d75119aec87ea711a880987b18cd7b239" +dependencies = [ + "yoke", + "zerofrom", + "zerovec-derive", +] + +[[package]] +name = "zerovec-derive" +version = "0.11.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "625dc425cab0dca6dc3c3319506e6593dcb08a9f387ea3b284dbd52a92c40555" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "zmij" +version = "1.0.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa" diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..54b403c --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,24 @@ +[workspace] +members = [ + "crates/cgcx-core", + "crates/cgcx-config", + "crates/cgcx-crypto", + "crates/cgcx-db", + "crates/cgcx-storage", + "crates/cgcx-content-typing", + "crates/cgcx-file-pipeline", + "crates/cgcx-moderation", + "crates/cgcx-bot", + "crates/cgcx-server", +] +resolver = "2" + +[workspace.package] +version = "0.1.0" +edition = "2021" +authors = ["cg.cx team"] + +[profile.release] +lto = "thin" +codegen-units = 1 +strip = true diff --git a/README.md b/README.md new file mode 100644 index 0000000..6aa0408 --- /dev/null +++ b/README.md @@ -0,0 +1,445 @@ +# cg.cx + +> End-to-end encrypted content sharing via Telegram — with a modern web frontend. + +**cg.cx** is a privacy-first file and text sharing platform built as a Telegram bot and Axum web service. Users upload content through a Telegram bot; the service encrypts every file with unique per-content keys, stores them securely, and shares them via short 12-character IDs. Recipients view or download content through a lightweight Svelte 5 web interface with automatic decryption on the fly. + +--- + +## Project Overview + +### What it is + +cg.cx lets Telegram users upload media, documents, or plain text and receive a short shareable link (`https://cg.cx/?cxid=AbCdEfGhIjKl`). All content is encrypted at rest using **XChaCha20-Poly1305** with per-file content encryption keys (CEKs) wrapped by a master key. The server never sees plaintext. + +### Key Features + +| Feature | Description | +| --------------------------- | --------------------------------------------------------------------------------------------------------------------------------------- | +| **End-to-End Encryption** | Every file is encrypted with a unique CEK using XChaCha20 secretstream; only the server (with the master key) can decrypt for delivery. | +| **Short Shareable IDs** | Content is addressed by 12-character alphanumeric IDs (e.g., `AbCdEfGhIjKl`). | +| **Auto-Destruct** | Uploaders can set a max view count; content self-destructs once the limit is reached. | +| **Password Protection** | Optional per-content passwords with Argon2id-hashed verification and HMAC-SHA256 session cookies. | +| **Admin Moderation** | Blacklist / whitelist user IDs, delete content, review reports via Telegram admin groups. | +| **Reporting** | Users can report content; reports are routed to review groups with inline admin actions. | +| **Streaming Decryption** | Large encrypted files are decrypted and streamed chunk-by-chunk without loading into memory. | +| **Content Typing & Safety** | Automatic MIME detection and render flags flag dangerous/executable files for safe handling. | + +### Architecture at a Glance + +``` +┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐ +│ Telegram User │────▶│ cgcx-bot │────▶│ cgcx-server │ +│ (upload / cmd) │ │ (Teloxide) │ │ (Axum / web) │ +└─────────────────┘ └─────────────────┘ └─────────────────┘ + │ │ + ▼ ▼ + ┌─────────────────┐ ┌─────────────────┐ + │ cgcx-file- │────▶│ Svelte 5 │ + │ pipeline │ │ Frontend │ + │ (encrypt/store) │ │ (viewer) │ + └─────────────────┘ └─────────────────┘ + │ + ▼ + ┌─────────────────┐ + │ SQLite3 + WAL │ + │ (metadata) │ + └─────────────────┘ +``` + +--- + +## Architecture + +cg.cx is organized as a **Rust workspace** with 10 focused crates. This modular design separates concerns, enables independent unit testing, and allows the bot and server binaries to pull in only the crates they need. + +| Crate | Purpose | +| --------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `cgcx-core` | Shared domain types: `ContentId`, `User`, `Content`, `ContentFile`, `Report`, error enums, and result types. Zero external dependencies beyond `serde` and `chrono`. | +| `cgcx-config` | Hierarchical configuration loader (`config/default.toml` → `config/local.toml` → `CGCX_*` env vars) with validation. | +| `cgcx-crypto` | Cryptographic primitives: XChaCha20 secretstream encryption/decryption, AES-KW key wrapping, BLAKE3 hashing, master key loading. | +| `cgcx-db` | SQLite access layer with `rusqlite`, embedded migrations (`rusqlite_migration`), and async repository patterns for users, content, files, reports, and admin actions. | +| `cgcx-storage` | Filesystem abstraction: path generation by MIME type, directory creation, temp file handling, and cleanup. | +| `cgcx-content-typing` | MIME type detection (`infer` + `mime_guess`) and render-flag computation for safe UI handling of dangerous files. | +| `cgcx-file-pipeline` | High-level upload orchestration: ingests raw bytes, detects type, encrypts via `cgcx-crypto`, stores via `cgcx-storage`, and records metadata via `cgcx-db`. | +| `cgcx-moderation` | Runtime moderation lists (blacklist / whitelist) loaded from JSON, with configurable share modes (`b` = blocklist, `w` = allowlist) and auto-reload. | +| `cgcx-bot` | **Binary crate** — Telegram bot built on `teloxide`. Handles dialogue flows, uploads, terms acceptance, reporting, and admin commands. | +| `cgcx-server` | **Binary crate** — Axum HTTP server. Serves the Svelte frontend, streams decrypted files, enforces view limits, and validates password cookies. | + +### Why a Modular Crate Structure? + +- **Separation of concerns**: Crypto logic cannot accidentally depend on Telegram bot internals; the database layer knows nothing about HTTP. +- **Testability**: Each crate can be unit-tested in isolation. `cgcx-core` and `cgcx-crypto` have no async runtime requirements, making them fast to test. +- **Independent deployment**: In the future, the bot and server could be built as separate container images sharing only the library crates. +- **Compile-time enforcement**: The workspace dependency graph guarantees that, for example, `cgcx-crypto` never touches the network or filesystem directly. + +--- + +## Security Design + +### Cryptographic Primitives + +| Layer | Algorithm | Purpose | +| -------------------- | ------------------------------------- | ---------------------------------------------------------------------------------------------------- | +| **Secretstream** | XChaCha20-Poly1305 (libsodium) | Encrypts file plaintext into an authenticated ciphertext stream. | +| **Key Wrapping** | AES-KW (AES-256 Key Wrap, RFC 3394) | Wraps each per-file CEK with the master key. | +| **Integrity Hash** | BLAKE3 | Computes a hash over the ciphertext stream (including the secretstream header) for tamper detection. | +| **Password Hashing** | Argon2id | Hashes optional per-content passwords. | +| **Cookie MAC** | HMAC-SHA256 | Integrity MAC for password-verification session cookies using constant-time comparison. | +| **ID Entropy** | Rejection sampling over `[A-Za-z0-9]` | 12-character IDs provide ~71 bits of entropy. | + +### Encryption Flow + +1. **Generate CEK**: For every uploaded file, `cgcx-crypto` generates a random 256-bit `ContentKey`. +2. **Encrypt**: The file is fed through `sodiumoxide::crypto::secretstream::xchacha20poly1305` in chunks (up to 1 MiB). The final chunk is tagged `Final`. +3. **Hash**: A running BLAKE3 hash covers the secretstream header and every ciphertext chunk. +4. **Wrap Key**: The CEK is wrapped with AES-KW using the 256-bit master key. The wrapped key + a version byte is stored in SQLite. +5. **Store**: The ciphertext file is moved from temp storage to its final path (`data/media|documents|text//...`). + +### Decryption Flow + +1. **Unwrap CEK**: The server unwraps the per-file CEK using the master key. +2. **Init Stream**: `DecryptStream` is initialized with the stored secretstream header. +3. **Stream**: Ciphertext is read from disk in ~1 MiB chunks, decrypted, and pushed to the HTTP response body via a Tokio channel. +4. **Verify**: If decryption fails (tampered or truncated data), the stream aborts and the client receives a broken stream. + +### Password Protection + +- Passwords are hashed with **Argon2id** and stored in the `contents` table. +- On successful verification, the server issues an `__Host-pw` cookie containing a base64-encoded `cxid:MAC` pair. +- The MAC is computed via **HMAC-SHA256** over the content ID using a server-side secret (derived from the master key). +- Cookie attributes: `Secure`, `HttpOnly`, `SameSite=Strict`, `Max-Age=3600`. + +### Master Key Handling + +- The master key is a 256-bit value loaded from either an environment variable (`CGCX_AES_MASTER_KEY`) or a file. +- If loaded from a file, the key is expected as 64 hex characters. +- On Unix systems, newly generated key files are automatically chmodded to `0o600`. +- The key fingerprint (first 8 bytes of BLAKE3 hash) is logged at startup for audit purposes; the full key is never logged. + +--- + +## Tech Stack + +| Layer | Technology | +| ----------------- | ----------------------------------------------------------- | +| **Backend** | Rust (edition 2021), Tokio async runtime | +| **Web Server** | Axum 0.7, Tower HTTP middleware | +| **Telegram Bot** | Teloxide 0.13 | +| **Frontend** | Svelte 5, Vite 5 | +| **Database** | SQLite 3 (WAL mode), `rusqlite` + `rusqlite_migration` | +| **Cryptography** | libsodium (via `sodiumoxide`), `aes-kw`, `blake3`, `argon2`, `hmac`, `sha2` | +| **Serialization** | `serde`, `serde_json` | +| **Observability** | `tracing` + `tracing-subscriber` | + +--- + +## Prerequisites + +- **Rust** toolchain (latest stable or nightly; the project builds on stable Rust 1.78+) +- **Node.js** 20+ and `npm` (for the frontend) +- **SQLite 3** (bundled via `rusqlite`, but the CLI is useful for inspection) +- A **Telegram Bot Token** from [@BotFather](https://t.me/botfather) +- A 256-bit master key (64 hex characters) for encryption + +--- + +## Building + +### Rust Workspace + +Build all crates (library + binaries): + +```bash +cargo build --workspace +``` + +Build optimized release binaries: + +```bash +cargo build --workspace --release +``` + +The release profile enables thin LTO, single codegen unit, and binary stripping for minimal size. + +### Frontend + +```bash +cd frontend +npm install +npm run build +``` + +The static assets are emitted to `frontend/dist/` and served by `cgcx-server` at runtime. + +--- + +## Configuration + +cg.cx uses a layered configuration system: + +1. `config/default.toml` — committed defaults +2. `config/default.example.toml` — local overrides (gitignored) +3. `CGCX_*` environment variables — runtime overrides + +Environment variables use double-underscore as a separator, e.g.: + +```bash +export CGCX_SERVER__PORT=3000 +export CGCX_TELEGRAM__BOT_TOKEN="your_token_here" +export CGCX_CRYPTO__AES_MASTER_KEY_SOURCE__TYPE="env" +export CGCX_CRYPTO__AES_MASTER_KEY_SOURCE__VAR="CGCX_AES_MASTER_KEY" +export CGCX_AES_MASTER_KEY="aabbccdd..." # 64 hex chars +``` + +### Config Sections + +| Section | Description | +| ----------------------------- | ----------------------------------------------------------------------------------------------------- | +| `[content]` | Auto-destruct behavior (`keep_content`, `share_mode`, `default_allow_download`, `default_max_views`). | +| `[crypto]` | Master key source (`env` or `file`). | +| `[telegram]` | Bot token and optional custom API URL. | +| `[groups]` | `admin_group_ids` and `review_group_ids` (Telegram chat IDs). | +| `[storage]` | Filesystem paths for `media`, `documents`, `text`, `temp`, and the streaming chunk size. | +| `[upload_limits]` | `max_batch_size`, `max_file_size_bytes`, `max_total_batch_bytes`. | +| `[server]` | `base_url`, `bind_address`, `port`. | +| `[rate_limiting]` | Per-minute request limits, burst capacity, and password-attempt limits. | +| `[logging]` | `level` (e.g., `info`, `debug`). | +| `[frontend.behavior_toggles]` | Feature flags for retro animations and particles. | + +### Validating Config + +Both binaries validate configuration on startup. Key checks include: + +- Chunk size between 8 MiB and 256 MiB +- Bot token is set and not the placeholder +- Upload and rate-limiting values are non-zero +- Master key source is fully specified + +--- + +## Running + +### Run the Web Server + +```bash +cargo run -p cgcx-server +``` + +The server binds to `127.0.0.1:8080` by default and serves: + +- `/` — Svelte frontend +- `/api/health` — health check +- `/api/content/:cxid` — metadata JSON +- `/api/content/:cxid/verify-password` — password verification +- `/api/content/:cxid/file/:file_idx` — streamed decrypted file +- `/assets/*` — static frontend assets + +### Run the Telegram Bot + +```bash +cargo run -p cgcx-bot +``` + +The bot processes updates from Telegram, handles user dialogues, and triggers the file pipeline for uploads. + +### Run Both Simultaneously + +Because the bot and server are separate binaries, they can run side-by-side sharing the same SQLite database and data directories: + +```bash +# Terminal 1 +cargo run -p cgcx-server + +# Terminal 2 +cargo run -p cgcx-bot +``` + +Ensure both processes point to the same database path and storage directories via shared configuration. + +--- + +## Database Migrations + +Migrations are managed by `rusqlite_migration` and embedded into the `cgcx-db` crate at compile time. + +- `migrations/001_init.sql` — Creates `users`, `contents`, `content_files`, `reports`, and `admin_actions` tables. +- `migrations/002_indexes.sql` — Adds performance indexes on foreign keys, status columns, and report state. + +On startup, both the bot and server call `db.run_migrations()`, which applies any pending migrations automatically. The database is opened with: + +- `PRAGMA journal_mode = WAL;` +- `PRAGMA foreign_keys = ON;` +- `PRAGMA busy_timeout = 5000;` + +### Manual Inspection + +```bash +sqlite3 data/db.sqlite ".schema" +sqlite3 data/cgcx.db ".indexes" +``` + +--- + +## Deployment + +### systemd Service + +Create `/etc/systemd/system/cgcx-server.service`: + +```ini +[Unit] +Description=cg.cx Web Server +After=network.target + +[Service] +Type=simple +User=cgcx +Group=cgcx +WorkingDirectory=/opt/cgcx +Environment="RUST_LOG=info" +Environment="CGCX_AES_MASTER_KEY=<64-hex-chars>" +ExecStart=/opt/cgcx/cgcx-server +Restart=on-failure +RestartSec=5 + +[Install] +WantedBy=multi-user.target +``` + +Create a similar service for `cgcx-bot`. Reload and enable: + +```bash +sudo systemctl daemon-reload +sudo systemctl enable --now cgcx-server cgcx-bot +``` + +### Reverse Proxy (nginx) + +```nginx +server { + listen 443 ssl http2; + server_name cg.cx; + + ssl_certificate /path/to/cert.pem; + ssl_certificate_key /path/to/key.pem; + + location / { + proxy_pass http://127.0.0.1:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + + # Support streaming + proxy_buffering off; + proxy_request_buffering off; + } +} +``` + +### TLS + +Use Let's Encrypt (certbot) or a managed TLS terminator. The `__Host-pw` cookie requires HTTPS (`Secure` flag). + +### File Permissions + +- The master key file (if used instead of env) **must** be readable only by the service user: + ```bash + chmod 600 /opt/cgcx/master.key + chown cgcx:cgcx /opt/cgcx/master.key + ``` +- Data directories (`data/media`, `data/documents`, `data/text`, `data/temp`) should be owned by the service user. + +--- + +## Administration + +### Admin Commands + +Admin commands are restricted to users in configured `admin_group_ids` who also have the `admin` role in the database. + +| Command | Usage | Description | +| ---------------- | -------------------------- | ---------------------------------------------------------------------------------------------- | +| `/reload` | `/reload` | Reloads moderation lists from disk (`data/blacklisted_ids.json`, `data/whitelisted_ids.json`). | +| `/blacklist_uid` | `/blacklist_uid ` | Blacklists a Telegram user ID and sets their role to `banned`. | +| `/whitelist_uid` | `/whitelist_uid ` | Whitelists a Telegram user ID (relevant in whitelist mode). | + +### Review Groups + +Reports submitted by users are forwarded to all configured `review_group_ids` with an inline keyboard: + +- **🗑 Delete** — Sets content status to `deleted`. +- **⛔ Blacklist User** — Blacklists the uploader and bans them. +- **📝 Ignore** — Dismisses the report. + +### Moderation Modes + +- **Blocklist mode (`share_mode = "b"`)**: Everyone can upload except blacklisted IDs. +- **Allowlist mode (`share_mode = "w"`)**: Only whitelisted IDs can upload. + +Moderation lists are hot-reloaded every 30 seconds by a background task, or immediately via `/reload`. + +--- + +## Development + +### Dev Mode (Frontend) + +```bash +cd frontend +npm install +npm run dev +``` + +Vite dev server runs separately; point `config/local.toml` `server.base_url` to your local frontend proxy if needed. + +### Dev Mode (Backend) + +```bash +# Server with tracing +cargo run -p cgcx-server + +# Bot with tracing +cargo run -p cgcx-bot +``` + +Set `RUST_LOG=debug` for verbose output: + +```bash +RUST_LOG=debug cargo run -p cgcx-server +``` + +### Testing + +Run workspace tests: + +```bash +cargo test --workspace +``` + +Individual crate tests: + +```bash +cargo test -p cgcx-core +cargo test -p cgcx-crypto +cargo test -p cgcx-content-typing +``` + +### Useful Debug Tips + +- Inspect SQLite directly: `sqlite3 data/db.sqlite "SELECT * FROM contents;"` +- Check moderation lists: `cat data/blacklisted_ids.json` +- Verify master key fingerprint in logs on startup. + +--- + +## License + +MIT License — see [LICENSE](LICENSE) for details. + +--- + +## Security Disclosure + +If you discover a security vulnerability, please do not open a public issue. Contact the maintainers directly through the admin channels configured in the bot. diff --git a/config/default.example.toml b/config/default.example.toml new file mode 100644 index 0000000..f937e85 --- /dev/null +++ b/config/default.example.toml @@ -0,0 +1,123 @@ +# ============================================================================ +# CG.CX Default Configuration +# ============================================================================ +# Copy this file to config/local.toml and override values there. +# Environment variables prefixed with CGCX__ also override these values. +# Example: CGCX_TELEGRAM__BOT_TOKEN=your_token +# ============================================================================ + +# ---------------------------------------------------------------------------- +# Content settings +# ---------------------------------------------------------------------------- +[content] +# Whether to keep content files on disk after deletion/blacklisting. +# If false, files are physically deleted when content is removed. +keep_content = true + +# Share mode: "b" = blacklist mode (allow unless blacklisted) +# "w" = whitelist mode (deny unless whitelisted) +share_mode = "b" + +# Default download permission for new uploads. +default_allow_download = true + +# Default max views before auto-destruction. Omit or comment out for no limit. +# default_max_views = 10 + +# ---------------------------------------------------------------------------- +# Cryptography +# ---------------------------------------------------------------------------- +[crypto] +# Master key source. The master key is used to wrap per-file content keys. +# Options: +# { type = "file", path = "data/master.key" } -- auto-generates if missing +# { type = "env", var = "CGCX_AES_MASTER_KEY" } -- reads from env var +# +# File-based is recommended for first setup because it auto-generates. +aes_master_key_source = { type = "file", path = "data/master.key" } + +# ---------------------------------------------------------------------------- +# Telegram Bot +# ---------------------------------------------------------------------------- +[telegram] +# Bot token from @BotFather. REQUIRED. +bot_token = "BOT_TOKEN_PLACEHOLDER" + +# Optional: local Bot API server URL for files > 20MB. +# Leave commented if using default Telegram servers. +# api_url = "http://localhost:8081" + +# ---------------------------------------------------------------------------- +# Telegram Groups +# ---------------------------------------------------------------------------- +[groups] +# Group IDs where admin commands (/reload, /blacklist_uid, /whitelist_uid) work. +# Negative IDs for supergroups. Example: [-1001234567890] +admin_group_ids = [] + +# Group IDs where reported content is forwarded for moderator review. +review_group_ids = [] + +# ---------------------------------------------------------------------------- +# Storage Paths +# ---------------------------------------------------------------------------- +[storage] +# Directory layout for encrypted files. +paths = { media = "./data/media", documents = "./data/documents", text = "./data/text", temp = "./data/temp" } + +# Chunk size for streaming upload/download. Clamped to [8 MiB, 256 MiB]. +chunk_size_bytes = 67_108_864 # 64 MiB + +# ---------------------------------------------------------------------------- +# Upload Limits +# ---------------------------------------------------------------------------- +[upload_limits] +# Maximum number of files per content entry. +max_batch_size = 10 + +# Maximum size of a single file (bytes). +max_file_size_bytes = 838_860_800 # 800 MiB + +# Maximum total size of all files in one batch (bytes). +max_total_batch_bytes = 2_147_483_648 # 2 GiB + +# ---------------------------------------------------------------------------- +# HTTP Server +# ---------------------------------------------------------------------------- +[server] +# Public base URL used in share links. MUST match your reverse proxy / TLS. +base_url = "https://cg.cx" + +# Bind address and port for the Axum server. +bind_address = "127.0.0.1" +port = 8080 + +# ---------------------------------------------------------------------------- +# Rate Limiting +# ---------------------------------------------------------------------------- +[rate_limiting] +# General API requests per minute (per IP). +requests_per_minute = 60 + +# Burst capacity for token bucket. +burst = 10 + +# Password attempt limit per minute (per content ID). +password_attempts_per_minute = 5 + +# ---------------------------------------------------------------------------- +# Logging +# ---------------------------------------------------------------------------- +[logging] +# Log level: trace, debug, info, warn, error +level = "info" + +# ---------------------------------------------------------------------------- +# Frontend Behavior Toggles +# ---------------------------------------------------------------------------- +[frontend.behavior_toggles] +# Enable retro CRT-style loading animation. +enable_retro_animation = true + +# Enable floating particle background (desktop only). +enable_particles = true diff --git a/crates/cgcx-bot/Cargo.toml b/crates/cgcx-bot/Cargo.toml new file mode 100644 index 0000000..f9b887e --- /dev/null +++ b/crates/cgcx-bot/Cargo.toml @@ -0,0 +1,32 @@ +[package] +name = "cgcx-bot" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "cgcx-bot" +path = "src/main.rs" + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +cgcx-config = { path = "../cgcx-config" } +cgcx-db = { path = "../cgcx-db" } +cgcx-file-pipeline = { path = "../cgcx-file-pipeline" } +cgcx-moderation = { path = "../cgcx-moderation" } +cgcx-storage = { path = "../cgcx-storage" } +cgcx-crypto = { path = "../cgcx-crypto" } +rusqlite = { version = "0.32", features = ["bundled", "chrono"] } +teloxide = { version = "0.13", features = ["macros"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync", "time"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = "0.4" +regex = "1" +argon2 = "0.5" +password-hash = "0.5" +hmac = "0.12" +sha2 = "0.10" +rand = "0.8" +fs2 = "0.4.3" diff --git a/crates/cgcx-bot/src/lib.rs b/crates/cgcx-bot/src/lib.rs new file mode 100644 index 0000000..5a4a774 --- /dev/null +++ b/crates/cgcx-bot/src/lib.rs @@ -0,0 +1 @@ +pub fn placeholder() {} diff --git a/crates/cgcx-bot/src/main.rs b/crates/cgcx-bot/src/main.rs new file mode 100644 index 0000000..5fa6864 --- /dev/null +++ b/crates/cgcx-bot/src/main.rs @@ -0,0 +1,988 @@ +use std::sync::Arc; +use teloxide::{ + dispatching::{dialogue::{InMemStorage, Storage}, UpdateFilterExt}, + net::Download, + prelude::*, + types::{ + InlineKeyboardButton, InlineKeyboardMarkup, Message, ParseMode, CallbackQuery, + ChatMemberStatus, UserId, + }, + utils::command::BotCommands, +}; +use cgcx_config::Config; +use cgcx_core::{ContentId, ContentStatus, ReportStatus, UserRole}; +use cgcx_crypto::MasterKey; +use cgcx_db::{Database, UserRepo, ContentRepo, ContentFileRepo, ReportRepo}; +use cgcx_file_pipeline::FilePipeline; +use cgcx_moderation::ModerationEngine; +use cgcx_storage::Storage as CgcxStorage; +use serde::{Deserialize, Serialize}; +use tracing::{info, warn}; + +#[derive(Clone, Default, Serialize, Deserialize)] +pub enum BotState { + #[default] + Start, + TermsPending, + MainMenu, + UploadStaging { items: Vec, upload_type: UploadType }, + UploadOptions { items: Vec, options: UploadOptions }, + UploadFinalizing, + Reporting, + ViewingPrevious { page: usize }, +} + +#[derive(Clone, Copy, Serialize, Deserialize)] +pub enum UploadType { + Media, + Document, + Text, +} + +impl Default for UploadType { + fn default() -> Self { UploadType::Media } +} + +#[derive(Clone, Serialize, Deserialize)] +pub struct StagedItem { + pub file_id: String, + pub file_name: String, + pub mime_type: String, + pub size: u64, + pub caption: Option, +} + +#[derive(Clone, Default, Serialize, Deserialize)] +pub struct UploadOptions { + pub max_views: Option, + pub allow_download: bool, + pub password: Option, +} + +type HandlerResult = Result<(), Box>; + +#[derive(Clone)] +struct BotDialogue { + chat_id: ChatId, + storage: Arc>, +} + +impl BotDialogue { + async fn get(&self) -> Result> { + Ok(self.storage.clone().get_dialogue(self.chat_id).await?.unwrap_or_default()) + } + async fn update(&self, state: BotState) -> Result<(), Box> { + Ok(self.storage.clone().update_dialogue(self.chat_id, state).await?) + } + async fn reset(&self) -> Result<(), Box> { + Ok(self.storage.clone().remove_dialogue(self.chat_id).await?) + } + async fn exit(&self) -> Result<(), Box> { + self.reset().await + } + async fn get_or_default(&self) -> Result> { + self.get().await + } +} + +#[derive(BotCommands, Clone)] +#[command(rename_rule = "lowercase", description = "Commands:")] +#[allow(dead_code)] +enum Command { + #[command(description = "Start the bot")] + Start, +} + +const TERMS_TEXT: &str = r#"Welcome to CG.CX + +Before using this service, you must read and accept the following terms: + +1. No Responsibility: This service is not responsible for whatever media or files are shared by users. + +2. Content Warning: Content may be uncomfortable, including bloody scenes or other disturbing material. + +3. Prohibited Content: You must NOT upload: + • Doxes / doxxing material + • CSAM or any sexual content involving minors + • Animal cruelty material + • Malware, stealers, droppers, loaders, ransomware, weaponized files, or intentionally malicious binaries/scripts + • Suicide guides, self-harm instructions, or material explicitly intended to facilitate suicide + +4. Enforcement: Violating these rules may result in immediate deletion, reporting to authorities, blacklisting, or moderator escalation. + +By clicking "Accept", you confirm you are at least 18 years old and agree to these terms."#; + +#[derive(Clone)] +struct BotContext { + db: Arc, + #[allow(dead_code)] + storage: Arc, + config: Arc, + master_key: Arc, + moderation: Arc, + pipeline: Arc, + sem: Arc, +} + +#[tokio::main] +async fn main() { + tracing_subscriber::fmt::init(); + + let config = Arc::new(Config::load().expect("Failed to load config")); + + let db = Arc::new(Database::open("data/db.sqlite").expect("Failed to open database")); + db.run_migrations().await.expect("Failed to run migrations"); + + let storage = Arc::new(CgcxStorage::new(config.storage.paths.clone())); + storage.ensure_dirs().await.expect("Failed to ensure storage dirs"); + + let master_key = match &config.crypto.aes_master_key_source { + cgcx_config::KeySource::Env { var } => MasterKey::load_from_env(var).expect("Failed to load master key"), + cgcx_config::KeySource::File { path } => MasterKey::load_from_file(path).expect("Failed to load master key"), + }; + master_key.log_startup(false); + + let moderation = Arc::new(ModerationEngine::new(&config, std::path::PathBuf::from("data"))); + moderation.load().await.expect("Failed to load moderation lists"); + + let pipeline = Arc::new(FilePipeline::new( + (*storage).clone(), + (*db).clone(), + (*config).clone(), + )); + + let sem = Arc::new(tokio::sync::Semaphore::new( + (1024 * 1024 * 1024 / config.storage.chunk_size_bytes.max(1)).max(1) + )); + + let ctx = BotContext { + db, storage, config: config.clone(), + master_key: Arc::new(master_key), moderation, pipeline, sem, + }; + + let bot = Bot::new(&config.telegram.bot_token); + info!("Bot started"); + + let handler = dptree::entry() + .branch(Update::filter_message().endpoint(handle_message)) + .branch(Update::filter_callback_query().endpoint(handle_callback)); + + Dispatcher::builder(bot, handler) + .dependencies(dptree::deps![InMemStorage::::new(), ctx]) + .enable_ctrlc_handler() + .build() + .dispatch() + .await; +} + +async fn handle_message( + bot: Bot, + msg: Message, + storage: Arc>, + ctx: BotContext, +) -> HandlerResult { + let user = match &msg.from { + Some(u) => u.clone(), + None => return Ok(()), + }; + let chat_id = msg.chat.id; + let user_id = user.id.0 as i64; + let dialogue = BotDialogue { chat_id, storage }; + + let user_repo = UserRepo::new(ctx.db.conn()); + user_repo.ensure_exists(user_id, user.username.as_deref(), &user.first_name).await?; + + let db_user = match user_repo.get(user_id).await? { + Some(u) => u, + None => return Ok(()), + }; + + if matches!(db_user.role, UserRole::Banned) || !ctx.moderation.is_allowed(user_id).await { + bot.send_message(chat_id, "[ Banned ] You are not allowed to use this service.").await?; + dialogue.exit().await?; + return Ok(()); + } + + // Admin commands in groups + if msg.chat.is_group() || msg.chat.is_supergroup() { + if let Some(text) = msg.text() { + let cmd = text.split_whitespace().next().unwrap_or(""); + match cmd { + "/reload" => { + if is_admin(&bot, msg.chat.id, user.id).await { + ctx.moderation.load().await?; + bot.send_message(chat_id, "Moderation lists reloaded.").await?; + } + return Ok(()); + } + "/blacklist_uid" => { + if is_admin(&bot, msg.chat.id, user.id).await { + handle_admin_blacklist_uid(&bot, chat_id, text, &ctx).await?; + } + return Ok(()); + } + "/whitelist_uid" => { + if is_admin(&bot, msg.chat.id, user.id).await { + handle_admin_whitelist_uid(&bot, chat_id, text, &ctx).await?; + } + return Ok(()); + } + _ => {} + } + } + } + + // DM commands + if let Some(text) = msg.text() { + let cmd = text.split_whitespace().next().unwrap_or("").split('@').next().unwrap_or(""); + match cmd { + "/start" => { + if db_user.accepted_terms_at.is_some() { + return send_main_menu(&bot, chat_id, &dialogue).await; + } else { + return send_terms(&bot, chat_id, &dialogue).await; + } + } + "/cancel" => { + dialogue.update(BotState::MainMenu).await?; + return send_main_menu(&bot, chat_id, &dialogue).await; + } + _ => {} + } + } + + let state = dialogue.get_or_default().await?; + match state { + BotState::Start | BotState::TermsPending => { + if msg.text().map(|t| t == "/start").unwrap_or(false) { + send_terms(&bot, chat_id, &dialogue).await?; + } + } + BotState::UploadStaging { items, upload_type } => { + handle_staging_message(&bot, msg, &dialogue, &ctx, items, upload_type).await?; + } + BotState::UploadOptions { items, options } => { + if let Some(text) = msg.text() { + if !text.starts_with('/') && options.password.is_none() { + let mut new_options = options.clone(); + new_options.password = Some(text.to_string()); + let items_cloned = items.clone(); + dialogue.update(BotState::UploadOptions { items: items_cloned.clone(), options: new_options.clone() }).await?; + refresh_options_message(&bot, chat_id, &items_cloned, &new_options).await?; + } + } + } + BotState::Reporting => { + if let Some(text) = msg.text() { + if !text.starts_with('/') { + handle_report(&bot, chat_id, user_id, text, &dialogue, &ctx).await?; + } + } + } + _ => {} + } + + Ok(()) +} + +async fn handle_callback( + bot: Bot, + q: CallbackQuery, + storage: Arc>, + ctx: BotContext, +) -> HandlerResult { + let data = q.data.as_deref().unwrap_or(""); + let user = q.from; + let user_id = user.id.0 as i64; + + if !ctx.moderation.is_allowed(user_id).await { + bot.answer_callback_query(&q.id).text("Not allowed").await?; + return Ok(()); + } + + let chat_id = q.message.as_ref().map(|m| m.chat().id).unwrap_or(ChatId(user_id)); + let dialogue = BotDialogue { chat_id, storage }; + + let parts: Vec<&str> = data.split(':').collect(); + if parts.len() < 3 || parts[0] != "v1" { + bot.answer_callback_query(&q.id).await?; + return Ok(()); + } + + match parts[1] { + "terms" => match parts[2] { + "accept" => { + let user_repo = UserRepo::new(ctx.db.conn()); + user_repo.set_accepted_terms(user_id).await?; + if let Some(msg) = &q.message { + bot.delete_message(chat_id, msg.id()).await.ok(); + } + send_main_menu(&bot, chat_id, &dialogue).await?; + } + "reject" => { + if let Some(msg) = &q.message { + bot.delete_message(chat_id, msg.id()).await.ok(); + } + dialogue.reset().await?; + } + _ => {} + }, + "menu" => match parts[2] { + "upload_media" => { + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?; + send_staging_message(&bot, chat_id, &[], UploadType::Media).await?; + } + "upload_doc" => { + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Document }).await?; + send_staging_message(&bot, chat_id, &[], UploadType::Document).await?; + } + "upload_text" => { + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Text }).await?; + send_staging_message(&bot, chat_id, &[], UploadType::Text).await?; + } + "prev_uploads" => { + dialogue.update(BotState::ViewingPrevious { page: 0 }).await?; + show_previous_uploads(&bot, chat_id, user_id, 0, &ctx).await?; + } + "report" => { + dialogue.update(BotState::Reporting).await?; + bot.send_message(chat_id, "Send me the content link or content ID to report.").await?; + } + "main" => { + send_main_menu(&bot, chat_id, &dialogue).await?; + } + _ => {} + }, + "stage" => match parts[2] { + "confirm" => { + let state = dialogue.get_or_default().await?; + if let BotState::UploadStaging { items, .. } = state { + if items.is_empty() { + bot.answer_callback_query(&q.id).text("No items to upload.").await?; + } else { + let options = UploadOptions { + allow_download: true, + ..Default::default() + }; + dialogue.update(BotState::UploadOptions { items, options: options.clone() }).await?; + refresh_options_message(&bot, chat_id, &vec![], &options).await?; + } + } + } + "cancel" => { + if let Some(msg) = &q.message { + bot.edit_message_text(chat_id, msg.id(), "Upload cancelled.").await.ok(); + } + dialogue.update(BotState::MainMenu).await?; + } + _ => {} + }, + "opt" => match parts[2] { + "toggle_destroy" => { + let state = dialogue.get_or_default().await?; + if let BotState::UploadOptions { items, options } = state { + let cycle = [None, Some(1), Some(3), Some(5), Some(10), Some(50)]; + let current = options.max_views; + let next = cycle.iter().skip_while(|&&x| x != current).nth(1).copied().unwrap_or(None); + let new_options = UploadOptions { max_views: next, ..options }; + dialogue.update(BotState::UploadOptions { items: items.clone(), options: new_options.clone() }).await?; + refresh_options_message(&bot, chat_id, &items, &new_options).await?; + } + } + "toggle_download" => { + let state = dialogue.get_or_default().await?; + if let BotState::UploadOptions { items, options } = state { + let new_options = UploadOptions { allow_download: !options.allow_download, ..options }; + dialogue.update(BotState::UploadOptions { items: items.clone(), options: new_options.clone() }).await?; + refresh_options_message(&bot, chat_id, &items, &new_options).await?; + } + } + "set_password" => { + bot.send_message(chat_id, "Send the password (max 32 chars) or /skip to skip.").await?; + } + "confirm_final" => { + let state = dialogue.get_or_default().await?; + if let BotState::UploadOptions { items, options } = state { + dialogue.update(BotState::UploadFinalizing).await?; + finalize_upload(&bot, chat_id, user_id, items, options, &dialogue, &ctx).await?; + } + } + "back" => { + dialogue.update(BotState::UploadStaging { items: vec![], upload_type: UploadType::Media }).await?; + send_staging_message(&bot, chat_id, &[], UploadType::Media).await?; + } + _ => {} + }, + "prev" => { + if parts[2] == "page" { + if let Ok(page) = parts[3].parse::() { + dialogue.update(BotState::ViewingPrevious { page }).await?; + show_previous_uploads(&bot, chat_id, user_id, page, &ctx).await?; + } + } + } + "admin" => { + if parts.len() >= 4 { + handle_admin_callback(&bot, chat_id, user_id, &parts, &ctx).await?; + } + } + _ => {} + } + + bot.answer_callback_query(&q.id).await?; + Ok(()) +} + +async fn send_terms(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult { + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("[ Accept ]", "v1:terms:accept"), + InlineKeyboardButton::callback("[ Decline ]", "v1:terms:reject"), + ]]); + bot.send_message(chat_id, TERMS_TEXT) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + dialogue.update(BotState::TermsPending).await?; + Ok(()) +} + +async fn send_main_menu(bot: &Bot, chat_id: ChatId, dialogue: &BotDialogue) -> HandlerResult { + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback("[ Upload Media ]", "v1:menu:upload_media"), + InlineKeyboardButton::callback("[ Upload Docs ]", "v1:menu:upload_doc"), + ], + vec![ + InlineKeyboardButton::callback("[ Upload Text ]", "v1:menu:upload_text"), + InlineKeyboardButton::callback("[ Previous Uploads ]", "v1:menu:prev_uploads"), + ], + vec![ + InlineKeyboardButton::callback("[ Report Content ]", "v1:menu:report"), + ], + ]); + bot.send_message(chat_id, "Choose from the menu below. Administrators can be contacted here: @harmfulmeowbot") + .reply_markup(keyboard) + .await?; + dialogue.update(BotState::MainMenu).await?; + Ok(()) +} + +async fn send_staging_message(bot: &Bot, chat_id: ChatId, items: &[StagedItem], upload_type: UploadType) -> HandlerResult { + let type_label = match upload_type { + UploadType::Media => "Media", + UploadType::Document => "Documents", + UploadType::Text => "Text", + }; + let text = if items.is_empty() { + format!("[ Staging {} (0/10) ]\n\nSend me files to add them.", type_label) + } else { + let list: String = items.iter().map(|i| format!("- {}\n", i.file_name)).collect(); + format!("[ Staging {} ({}/10) ]\n\n{}", type_label, items.len(), list) + }; + + let keyboard = InlineKeyboardMarkup::new(vec![vec![ + InlineKeyboardButton::callback("[ Confirm ]", "v1:stage:confirm"), + InlineKeyboardButton::callback("[ Cancel ]", "v1:stage:cancel"), + ]]); + + bot.send_message(chat_id, text) + .reply_markup(keyboard) + .await?; + Ok(()) +} + +async fn handle_staging_message( + bot: &Bot, + msg: Message, + dialogue: &BotDialogue, + ctx: &BotContext, + mut items: Vec, + upload_type: UploadType, +) -> HandlerResult { + if items.len() >= ctx.config.upload_limits.max_batch_size { + bot.send_message(msg.chat.id, "Maximum batch size reached.").await?; + return Ok(()); + } + + let mut new_item = None; + + match upload_type { + UploadType::Media => { + if let Some(photo) = msg.photo() { + let largest = photo.iter().max_by_key(|p| p.file.size); + if let Some(p) = largest { + new_item = Some(StagedItem { + file_id: p.file.id.clone(), + file_name: format!("photo_{}.jpg", items.len()), + mime_type: "image/jpeg".to_string(), + size: p.file.size as u64, + caption: msg.caption().map(|s| s.to_string()), + }); + } + } else if let Some(video) = msg.video() { + new_item = Some(StagedItem { + file_id: video.file.id.clone(), + file_name: video.file_name.clone().unwrap_or_else(|| format!("video_{}.mp4", items.len())), + mime_type: "video/mp4".to_string(), + size: video.file.size as u64, + caption: msg.caption().map(|s| s.to_string()), + }); + } else if let Some(audio) = msg.audio() { + new_item = Some(StagedItem { + file_id: audio.file.id.clone(), + file_name: audio.file_name.clone().unwrap_or_else(|| format!("audio_{}.mp3", items.len())), + mime_type: "audio/mpeg".to_string(), + size: audio.file.size as u64, + caption: msg.caption().map(|s| s.to_string()), + }); + } + } + UploadType::Document => { + if let Some(doc) = msg.document() { + new_item = Some(StagedItem { + file_id: doc.file.id.clone(), + file_name: doc.file_name.clone().unwrap_or_else(|| format!("file_{}", items.len())), + mime_type: doc.mime_type.clone().map(|m| m.to_string()).unwrap_or_else(|| "application/octet-stream".to_string()), + size: doc.file.size as u64, + caption: msg.caption().map(|s| s.to_string()), + }); + } + } + UploadType::Text => { + if let Some(text) = msg.text() { + if !text.starts_with('/') { + new_item = Some(StagedItem { + file_id: format!("text://{}", msg.id.0), + file_name: "text.txt".to_string(), + mime_type: "text/plain".to_string(), + size: text.len() as u64, + caption: Some(text.to_string()), + }); + } + } + } + } + + if let Some(item) = new_item { + items.push(item); + dialogue.update(BotState::UploadStaging { items: items.clone(), upload_type }).await?; + send_staging_message(bot, msg.chat.id, &items, upload_type).await?; + } + + Ok(()) +} + +async fn refresh_options_message( + bot: &Bot, + chat_id: ChatId, + _items: &[StagedItem], + options: &UploadOptions, +) -> HandlerResult { + let destroy_text = match options.max_views { + Some(n) => format!("Auto-destroy: {} views", n), + None => "Auto-destroy: Off".to_string(), + }; + let download_text = if options.allow_download { + "Allow download: Yes" + } else { + "Allow download: No" + }; + let password_text = if options.password.is_some() { + "Password: Set" + } else { + "Password: None" + }; + + let text = format!( + "[ Upload Options ]\n\n{}\n{}\n{}\n\nConfirm when ready.", + destroy_text, download_text, password_text + ); + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback("[ Toggle Destroy ]", "v1:opt:toggle_destroy"), + InlineKeyboardButton::callback("[ Toggle Download ]", "v1:opt:toggle_download"), + ], + vec![ + InlineKeyboardButton::callback("[ Set Password ]", "v1:opt:set_password"), + ], + vec![ + InlineKeyboardButton::callback("[ Back ]", "v1:opt:back"), + InlineKeyboardButton::callback("[ Confirm & Upload ]", "v1:opt:confirm_final"), + ], + ]); + + bot.send_message(chat_id, text) + .reply_markup(keyboard) + .await?; + Ok(()) +} + +async fn finalize_upload( + bot: &Bot, + chat_id: ChatId, + user_id: i64, + items: Vec, + options: UploadOptions, + dialogue: &BotDialogue, + ctx: &BotContext, +) -> HandlerResult { + let status_msg = bot.send_message(chat_id, "[ Encrypting and storing... ]").await?; + + let total_size: u64 = items.iter().map(|i| i.size).sum(); + if total_size > ctx.config.upload_limits.max_total_batch_bytes { + bot.edit_message_text(chat_id, status_msg.id, "[ Error: total batch size exceeds limit. ]").await?; + dialogue.update(BotState::MainMenu).await?; + return Ok(()); + } + + // Check available disk space in temp dir + if let Ok(temp_path) = std::fs::canonicalize(&ctx.config.storage.paths.temp) { + if let Ok(info) = fs2::available_space(&temp_path) { + if info < total_size * 2 { + bot.edit_message_text(chat_id, status_msg.id, "[ Error: insufficient storage space. ]").await?; + dialogue.update(BotState::MainMenu).await?; + return Ok(()); + } + } + } + + let content_id = ContentId::generate(); + let repo = ContentRepo::new(ctx.db.conn()); + let mut attempts = 0; + while repo.get(&content_id).await?.is_some() && attempts < 5 { + attempts += 1; + } + + let password_hash = options.password.as_ref().map(|p| { + use argon2::{Argon2, PasswordHasher, password_hash::SaltString}; + use rand::rngs::OsRng; + let salt = SaltString::generate(&mut OsRng); + let argon2 = Argon2::default(); + argon2.hash_password(p.as_bytes(), &salt) + .map(|h| h.to_string()) + .unwrap_or_default() + }); + + ctx.pipeline.create_content_entry( + content_id.clone(), + user_id, + options.max_views, + options.allow_download, + password_hash, + ).await?; + + for (idx, item) in items.iter().enumerate() { + let result = if item.file_id.starts_with("text://") { + let data = item.caption.clone().unwrap_or_default().into_bytes(); + let mut cursor = std::io::Cursor::new(data); + ctx.pipeline.ingest_file( + &content_id, + idx as u32, + &mut cursor, + &item.file_name, + &ctx.master_key, + &ctx.sem, + ).await + } else { + match bot.get_file(&item.file_id).await { + Ok(file) => { + let mut data = Vec::new(); + if let Err(e) = bot.download_file(&file.path, &mut data).await { + warn!("Download error: {}", e); + continue; + } + let mut cursor = std::io::Cursor::new(data); + ctx.pipeline.ingest_file( + &content_id, + idx as u32, + &mut cursor, + &item.file_name, + &ctx.master_key, + &ctx.sem, + ).await + } + Err(e) => { + warn!("Get file error: {}", e); + continue; + } + } + }; + + if let Err(e) = result { + warn!("Ingest error: {}", e); + } + } + + ctx.pipeline.activate_content(&content_id).await?; + + let base_url = &ctx.config.server.base_url; + let link = format!("{}/?cxid={}", base_url, content_id.as_str()); + + let mut attrs = vec![]; + if let Some(v) = options.max_views { + attrs.push(format!("auto-burn after {}", v)); + } + if options.allow_download { + attrs.push("download allowed".to_string()); + } + if options.password.is_some() { + attrs.push("password set".to_string()); + } + let attr_text = if attrs.is_empty() { + "no special options".to_string() + } else { + attrs.join(", ") + }; + + let result_text = format!( + "[ Upload Complete ]\n\nLink: {}\n\nFiles: {} | {}", + link, items.len(), attr_text + ); + + bot.edit_message_text(chat_id, status_msg.id, result_text) + .parse_mode(ParseMode::Html) + .await?; + + dialogue.update(BotState::MainMenu).await?; + Ok(()) +} + +async fn show_previous_uploads( + bot: &Bot, + chat_id: ChatId, + user_id: i64, + page: usize, + ctx: &BotContext, +) -> HandlerResult { + let repo = ContentRepo::new(ctx.db.conn()); + let total = repo.count_by_user(user_id).await?; + let items = repo.list_by_user(user_id, 10, page * 10).await?; + let total_pages = (total + 9) / 10; + + if items.is_empty() { + bot.send_message(chat_id, "You have no uploads.").await?; + return Ok(()); + } + + let base_url = &ctx.config.server.base_url; + let mut text = format!("[ Your Uploads ] Page {}/{}\n\n", page + 1, total_pages.max(1)); + for content in &items { + let file_repo = ContentFileRepo::new(ctx.db.conn()); + let files = file_repo.list_by_content(&content.id).await?; + let mut attrs = vec![]; + if let Some(v) = content.max_views { + attrs.push(format!("auto-burn after {}", v)); + } + if content.allow_download { + attrs.push("download allowed".to_string()); + } + if content.password_hash.is_some() { + attrs.push("password set".to_string()); + } + let attr_text = if attrs.is_empty() { "no options".to_string() } else { attrs.join(", ") }; + + text.push_str(&format!( + "- {} ({} files) [{}]\n {}?cxid={}\n\n", + content.id.as_str(), files.len(), attr_text, base_url, content.id.as_str() + )); + } + + let mut buttons = vec![]; + if page > 0 { + buttons.push(InlineKeyboardButton::callback("<<", format!("v1:prev:page:{}", page - 1))); + } + buttons.push(InlineKeyboardButton::callback(format!("Page {}/{}", page + 1, total_pages.max(1)), "noop")); + if page + 1 < total_pages { + buttons.push(InlineKeyboardButton::callback(">>", format!("v1:prev:page:{}", page + 1))); + } + + let keyboard = InlineKeyboardMarkup::new(vec![buttons, vec![ + InlineKeyboardButton::callback("[ Main Menu ]", "v1:menu:main"), + ]]); + + bot.send_message(chat_id, text) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await?; + + Ok(()) +} + +async fn handle_report( + bot: &Bot, + chat_id: ChatId, + reporter_id: i64, + text: &str, + dialogue: &BotDialogue, + ctx: &BotContext, +) -> HandlerResult { + let cxid = extract_cxid(text).ok_or("Invalid content ID or link")?; + let content_id = ContentId::try_from(cxid.as_str())?; + + let repo = ContentRepo::new(ctx.db.conn()); + let content = repo.get(&content_id).await?.ok_or("Content not found")?; + + let report_repo = ReportRepo::new(ctx.db.conn()); + let report_id = report_repo.insert(&content_id, reporter_id, text).await?; + + for &group_id in &ctx.config.groups.review_group_ids { + let report_text = format!( + "[ NEW REPORT ] #{}\n\nCXID: {}\nReporter: {}\nOwner: {}\nUploaded: {}\nFiles: {}", + report_id, + cxid, + reporter_id, + content.user_id, + content.created_at.format("%Y-%m-%d %H:%M"), + 1 + ); + + let keyboard = InlineKeyboardMarkup::new(vec![ + vec![ + InlineKeyboardButton::callback("[ Delete + Blacklist ]", format!("v1:admin:delblk:{}", report_id)), + InlineKeyboardButton::callback("[ Delete Only ]", format!("v1:admin:del:{}", report_id)), + ], + vec![ + InlineKeyboardButton::callback("[ Blacklist Only ]", format!("v1:admin:blk:{}", report_id)), + InlineKeyboardButton::callback("[ Ignore ]", format!("v1:admin:ign:{}", report_id)), + ], + ]); + + bot.send_message(ChatId(group_id), report_text) + .parse_mode(ParseMode::Html) + .reply_markup(keyboard) + .await + .ok(); + } + + bot.send_message(chat_id, "Report submitted. Moderators will review it shortly.").await?; + dialogue.update(BotState::MainMenu).await?; + Ok(()) +} + +async fn handle_admin_callback( + bot: &Bot, + chat_id: ChatId, + user_id: i64, + parts: &[&str], + ctx: &BotContext, +) -> HandlerResult { + if !is_admin_in_chat(bot, chat_id, UserId(user_id as u64)).await { + bot.send_message(chat_id, "Unauthorized.").await?; + return Ok(()); + } + + let report_id = parts[3].parse::().unwrap_or(0); + let report_repo = ReportRepo::new(ctx.db.conn()); + let report = match report_repo.get(report_id).await? { + Some(r) => r, + None => { + bot.send_message(chat_id, "Report not found.").await?; + return Ok(()); + } + }; + + let content_repo = ContentRepo::new(ctx.db.conn()); + let content = match content_repo.get(&report.content_id).await? { + Some(c) => c, + None => { + bot.send_message(chat_id, "Content not found.").await?; + return Ok(()); + } + }; + + match parts[2] { + "delblk" => { + ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok(); + content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok(); + ctx.moderation.blacklist(content.user_id).await.ok(); + let user_repo = UserRepo::new(ctx.db.conn()); + user_repo.set_role(content.user_id, "banned").await.ok(); + report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok(); + bot.send_message(chat_id, format!("Deleted content {} and blacklisted user {}", report.content_id.as_str(), content.user_id)) + .parse_mode(ParseMode::Html).await?; + } + "del" => { + ctx.pipeline.delete_content(&report.content_id, !ctx.config.content.keep_content).await.ok(); + content_repo.set_status(&report.content_id, ContentStatus::Deleted).await.ok(); + report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok(); + bot.send_message(chat_id, format!("Deleted content {}", report.content_id.as_str())) + .parse_mode(ParseMode::Html).await?; + } + "blk" => { + ctx.moderation.blacklist(content.user_id).await.ok(); + let user_repo = UserRepo::new(ctx.db.conn()); + user_repo.set_role(content.user_id, "banned").await.ok(); + report_repo.resolve(report_id, ReportStatus::Actioned, user_id).await.ok(); + bot.send_message(chat_id, format!("Blacklisted user {}", content.user_id)) + .parse_mode(ParseMode::Html).await?; + } + "ign" => { + report_repo.resolve(report_id, ReportStatus::Dismissed, user_id).await.ok(); + bot.send_message(chat_id, format!("Ignored report #{}", report_id)) + .parse_mode(ParseMode::Html).await?; + } + _ => {} + } + + Ok(()) +} + +fn extract_cxid(input: &str) -> Option { + let re = regex::Regex::new(r"[?&]cxid=([a-zA-Z0-9]{12})").ok()?; + if let Some(cap) = re.captures(input) { + return cap.get(1).map(|m| m.as_str().to_string()); + } + let trimmed = input.trim(); + if trimmed.len() == 12 && trimmed.chars().all(|c| c.is_ascii_alphanumeric()) { + return Some(trimmed.to_string()); + } + None +} + +async fn is_admin(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool { + match bot.get_chat_member(chat_id, user_id).await { + Ok(member) => { + matches!(member.status(), ChatMemberStatus::Administrator | ChatMemberStatus::Owner) + } + Err(_) => false, + } +} + +async fn is_admin_in_chat(bot: &Bot, chat_id: ChatId, user_id: UserId) -> bool { + is_admin(bot, chat_id, user_id).await +} + +async fn handle_admin_blacklist_uid( + bot: &Bot, + chat_id: ChatId, + text: &str, + ctx: &BotContext, +) -> HandlerResult { + let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); + if let Some(uid) = uid { + ctx.moderation.blacklist(uid).await?; + let user_repo = UserRepo::new(ctx.db.conn()); + user_repo.set_role(uid, "banned").await?; + bot.send_message(chat_id, format!("Blacklisted UID {}", uid)) + .parse_mode(ParseMode::Html).await?; + } + Ok(()) +} + +async fn handle_admin_whitelist_uid( + bot: &Bot, + chat_id: ChatId, + text: &str, + ctx: &BotContext, +) -> HandlerResult { + let uid = text.split_whitespace().nth(1).and_then(|s| s.parse::().ok()); + if let Some(uid) = uid { + ctx.moderation.remove_blacklist(uid).await?; + let user_repo = UserRepo::new(ctx.db.conn()); + user_repo.set_role(uid, "user").await?; + bot.send_message(chat_id, format!("Whitelisted UID {}", uid)) + .parse_mode(ParseMode::Html).await?; + } + Ok(()) +} diff --git a/crates/cgcx-config/Cargo.toml b/crates/cgcx-config/Cargo.toml new file mode 100644 index 0000000..7d58311 --- /dev/null +++ b/crates/cgcx-config/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cgcx-config" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +serde = { version = "1.0", features = ["derive"] } +config = "0.14" +tokio = { version = "1", features = ["fs", "sync", "time"] } +tracing = "0.1" diff --git a/crates/cgcx-config/src/lib.rs b/crates/cgcx-config/src/lib.rs new file mode 100644 index 0000000..f772bdb --- /dev/null +++ b/crates/cgcx-config/src/lib.rs @@ -0,0 +1,192 @@ +use serde::{Deserialize, Serialize}; +use std::collections::HashMap; +use std::path::PathBuf; + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Config { + pub content: ContentConfig, + pub crypto: CryptoConfig, + pub telegram: TelegramConfig, + pub groups: GroupsConfig, + pub storage: StorageConfig, + pub upload_limits: UploadLimits, + pub server: ServerConfig, + pub rate_limiting: RateLimitConfig, + pub logging: LoggingConfig, + pub frontend: FrontendConfig, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ContentConfig { + pub keep_content: bool, + pub share_mode: ShareMode, + pub default_allow_download: bool, + #[serde(default)] + pub default_max_views: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(rename_all = "lowercase")] +pub enum ShareMode { + B, + W, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct CryptoConfig { + pub aes_master_key_source: KeySource, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +#[serde(tag = "type", rename_all = "snake_case")] +pub enum KeySource { + File { path: PathBuf }, + Env { var: String }, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct TelegramConfig { + pub bot_token: String, + pub api_url: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct GroupsConfig { + pub admin_group_ids: Vec, + pub review_group_ids: Vec, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StorageConfig { + pub paths: StoragePaths, + pub chunk_size_bytes: usize, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct StoragePaths { + pub media: PathBuf, + pub documents: PathBuf, + pub text: PathBuf, + pub temp: PathBuf, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct UploadLimits { + pub max_batch_size: usize, + pub max_file_size_bytes: u64, + pub max_total_batch_bytes: u64, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ServerConfig { + pub base_url: String, + pub bind_address: String, + pub port: u16, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct RateLimitConfig { + pub requests_per_minute: u32, + pub burst: u32, + pub password_attempts_per_minute: u32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct LoggingConfig { + pub level: String, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct FrontendConfig { + pub behavior_toggles: HashMap, +} + +impl Config { + pub fn load() -> Result { + let s = config::Config::builder() + .add_source(config::File::with_name("config/default")) + .add_source(config::File::with_name("config/local").required(false)) + .add_source( + config::Environment::with_prefix("CGCX") + .separator("__") + .try_parsing(true) + .list_separator(","), + ) + .build() + .map_err(|e| cgcx_core::CgcxError::Config(e.to_string()))?; + let cfg: Config = s.try_deserialize() + .map_err(|e| cgcx_core::CgcxError::Config(e.to_string()))?; + cfg.validate()?; + Ok(cfg) + } + + pub fn validate(&self) -> Result<(), cgcx_core::CgcxError> { + let chunk = self.storage.chunk_size_bytes; + const MIN: usize = 8 * 1024 * 1024; + const MAX: usize = 256 * 1024 * 1024; + if chunk < MIN || chunk > MAX { + return Err(cgcx_core::CgcxError::Config(format!( + "chunk_size_bytes must be between {} and {}, got {}", + MIN, MAX, chunk + ))); + } + + if self.telegram.bot_token.is_empty() || self.telegram.bot_token == "BOT_TOKEN_PLACEHOLDER" { + return Err(cgcx_core::CgcxError::Config( + "telegram.bot_token must be set to a valid bot token".into() + )); + } + + if self.server.port == 0 { + return Err(cgcx_core::CgcxError::Config( + "server.port must be > 0".into() + )); + } + + if self.server.base_url.is_empty() { + return Err(cgcx_core::CgcxError::Config( + "server.base_url must be set".into() + )); + } + + if self.upload_limits.max_batch_size == 0 + || self.upload_limits.max_file_size_bytes == 0 + || self.upload_limits.max_total_batch_bytes == 0 + { + return Err(cgcx_core::CgcxError::Config( + "upload_limits must all be > 0".into() + )); + } + + if self.rate_limiting.requests_per_minute == 0 + || self.rate_limiting.burst == 0 + || self.rate_limiting.password_attempts_per_minute == 0 + { + return Err(cgcx_core::CgcxError::Config( + "rate_limiting values must all be > 0".into() + )); + } + + if self.logging.level.is_empty() { + return Err(cgcx_core::CgcxError::Config( + "logging.level must be set".into() + )); + } + + match &self.crypto.aes_master_key_source { + KeySource::File { path } if path.as_os_str().is_empty() => { + return Err(cgcx_core::CgcxError::Config( + "crypto.aes_master_key_source.file path must not be empty".into() + )); + } + KeySource::Env { var } if var.is_empty() => { + return Err(cgcx_core::CgcxError::Config( + "crypto.aes_master_key_source.env var must not be empty".into() + )); + } + _ => {} + } + + Ok(()) + } +} diff --git a/crates/cgcx-content-typing/Cargo.toml b/crates/cgcx-content-typing/Cargo.toml new file mode 100644 index 0000000..eae4346 --- /dev/null +++ b/crates/cgcx-content-typing/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "cgcx-content-typing" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +infer = "0.16" +mime_guess = "2" diff --git a/crates/cgcx-content-typing/src/lib.rs b/crates/cgcx-content-typing/src/lib.rs new file mode 100644 index 0000000..361ecbc --- /dev/null +++ b/crates/cgcx-content-typing/src/lib.rs @@ -0,0 +1,87 @@ +pub const RENDER_IMAGE: u32 = 1 << 0; +pub const RENDER_VIDEO: u32 = 1 << 1; +pub const RENDER_AUDIO: u32 = 1 << 2; +pub const RENDER_MARKDOWN: u32 = 1 << 3; +pub const RENDER_TEXT: u32 = 1 << 4; +pub const RENDER_DOCUMENT: u32 = 1 << 5; +pub const RENDER_EXECUTABLE: u32 = 1 << 6; +pub const RENDER_DANGEROUS: u32 = 1 << 7; +pub const RENDER_NO_INLINE: u32 = 1 << 8; + +const DANGEROUS_EXTENSIONS: &[&str] = &[ + "exe", "scr", "bat", "cmd", "sh", "dll", "so", "dylib", "jar", "msi", "com", "app", "apk", +]; + +const DANGEROUS_MIME_TYPES: &[&str] = &[ + "text/html", + "text/javascript", + "text/css", + "application/javascript", + "application/ecmascript", +]; + +pub fn detect_mime_type(data: &[u8], file_name: &str) -> String { + if let Some(kind) = infer::get(data) { + let mime = kind.mime_type(); + if !mime.is_empty() && mime != "application/octet-stream" { + return mime.to_string(); + } + } + mime_guess::from_path(file_name) + .first_or_octet_stream() + .to_string() +} + +pub fn compute_render_flags(mime_type: &str, file_name: &str, data: &[u8]) -> u32 { + let mut flags = 0u32; + + if mime_type.starts_with("image/") { + flags |= RENDER_IMAGE; + } else if mime_type.starts_with("video/") { + flags |= RENDER_VIDEO; + } else if mime_type.starts_with("audio/") { + flags |= RENDER_AUDIO; + } else if mime_type == "text/markdown" + || file_name.ends_with(".md") + || file_name.ends_with(".markdown") + { + flags |= RENDER_MARKDOWN | RENDER_TEXT; + } else if mime_type.starts_with("text/") { + flags |= RENDER_TEXT; + } else if mime_type == "application/pdf" || mime_type.starts_with("application/vnd.") { + flags |= RENDER_DOCUMENT; + } + + if DANGEROUS_MIME_TYPES.contains(&mime_type) { + flags |= RENDER_DANGEROUS | RENDER_NO_INLINE; + } + + let ext = file_name.rsplit('.').next().unwrap_or("").to_lowercase(); + if DANGEROUS_EXTENSIONS.contains(&ext.as_str()) { + flags |= RENDER_EXECUTABLE | RENDER_DANGEROUS | RENDER_NO_INLINE; + } + + if let Some(kind) = infer::get(data) { + let mime = kind.mime_type(); + if mime == "application/x-executable" + || mime == "application/x-msdownload" + || mime == "application/x-pie-executable" + { + flags |= RENDER_EXECUTABLE | RENDER_DANGEROUS | RENDER_NO_INLINE; + } + } + + if flags & (RENDER_EXECUTABLE | RENDER_DANGEROUS) != 0 { + flags |= RENDER_NO_INLINE; + } + + flags +} + +pub fn is_dangerous(flags: u32) -> bool { + flags & RENDER_DANGEROUS != 0 +} + +pub fn should_inline(flags: u32) -> bool { + flags & RENDER_NO_INLINE == 0 +} diff --git a/crates/cgcx-core/Cargo.toml b/crates/cgcx-core/Cargo.toml new file mode 100644 index 0000000..b6cf65a --- /dev/null +++ b/crates/cgcx-core/Cargo.toml @@ -0,0 +1,10 @@ +[package] +name = "cgcx-core" +version.workspace = true +edition.workspace = true + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +thiserror = "1.0" +chrono = { version = "0.4", features = ["serde"] } +rand = "0.8" diff --git a/crates/cgcx-core/src/id.rs b/crates/cgcx-core/src/id.rs new file mode 100644 index 0000000..67bccbe --- /dev/null +++ b/crates/cgcx-core/src/id.rs @@ -0,0 +1,75 @@ +use rand::Rng; +use serde::{Deserialize, Serialize}; +use std::fmt; + +const CXID_ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; +const CXID_LENGTH: usize = 12; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash, Deserialize, Serialize)] +pub struct ContentId(String); + +impl ContentId { + pub fn generate() -> Self { + let mut rng = rand::thread_rng(); + let alphabet_len = CXID_ALPHABET.len() as u32; + let mut s = String::with_capacity(CXID_LENGTH); + while s.len() < CXID_LENGTH { + let val: u32 = rng.gen(); + // Rejection sampling: only use values that map uniformly + let max = u32::MAX - (u32::MAX % alphabet_len); + if val < max { + s.push(CXID_ALPHABET[(val % alphabet_len) as usize] as char); + } + } + Self(s) + } + + pub fn is_valid(s: &str) -> bool { + s.len() == CXID_LENGTH + && s.bytes().all(|b| CXID_ALPHABET.contains(&b)) + } + + pub fn as_str(&self) -> &str { + &self.0 + } + + /// Construct from a string without validation. + /// Only use when the source is trusted (e.g., DB row with FK constraint). + pub fn new_unchecked(s: String) -> Self { + Self(s) + } +} + +impl fmt::Display for ContentId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.0) + } +} + +impl AsRef for ContentId { + fn as_ref(&self) -> &str { + &self.0 + } +} + +impl TryFrom for ContentId { + type Error = crate::CgcxError; + fn try_from(value: String) -> crate::Result { + if Self::is_valid(&value) { + Ok(Self(value)) + } else { + Err(crate::CgcxError::InvalidContentId(value)) + } + } +} + +impl TryFrom<&str> for ContentId { + type Error = crate::CgcxError; + fn try_from(value: &str) -> crate::Result { + if Self::is_valid(value) { + Ok(Self(value.to_string())) + } else { + Err(crate::CgcxError::InvalidContentId(value.to_string())) + } + } +} diff --git a/crates/cgcx-core/src/lib.rs b/crates/cgcx-core/src/lib.rs new file mode 100644 index 0000000..828c13f --- /dev/null +++ b/crates/cgcx-core/src/lib.rs @@ -0,0 +1,37 @@ +pub mod id; +pub mod models; + +pub use id::ContentId; +pub use models::*; + +#[derive(thiserror::Error, Debug)] +pub enum CgcxError { + #[error("invalid content id: {0}")] + InvalidContentId(String), + #[error("crypto error: {0}")] + Crypto(String), + #[error("database error: {0}")] + Database(String), + #[error("storage error: {0}")] + Storage(String), + #[error("config error: {0}")] + Config(String), + #[error("moderation error: {0}")] + Moderation(String), + #[error("not found")] + NotFound, + #[error("unauthorized")] + Unauthorized, + #[error("forbidden")] + Forbidden, + #[error("rate limited")] + RateLimited, + #[error("bad request: {0}")] + BadRequest(String), + #[error("insufficient storage")] + InsufficientStorage, + #[error("io error: {0}")] + Io(#[from] std::io::Error), +} + +pub type Result = std::result::Result; diff --git a/crates/cgcx-core/src/models.rs b/crates/cgcx-core/src/models.rs new file mode 100644 index 0000000..c4140a6 --- /dev/null +++ b/crates/cgcx-core/src/models.rs @@ -0,0 +1,86 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; + +use crate::id::ContentId; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum UserRole { + User, + Admin, + Banned, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ContentStatus { + Staged, + Active, + Deleted, + Blacklisted, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize, Serialize)] +pub enum ReportStatus { + Open, + Dismissed, + Actioned, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct User { + pub id: i64, + pub telegram_username: Option, + pub first_name: String, + pub role: UserRole, + pub accepted_terms_at: Option>, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Content { + pub id: ContentId, + pub user_id: i64, + pub status: ContentStatus, + pub view_count: u64, + pub max_views: Option, + pub allow_download: bool, + pub password_hash: Option, + pub created_at: DateTime, + pub deleted_at: Option>, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct ContentFile { + pub content_id: ContentId, + pub file_index: u32, + pub original_name: String, + pub stored_path: std::path::PathBuf, + pub mime_type: String, + pub size_bytes: u64, + pub ciphertext_size_bytes: u64, + pub encrypted_key_wrapped: Vec, + pub encrypted_hash: Vec, + pub render_flags: u32, + pub created_at: DateTime, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct Report { + pub id: i64, + pub content_id: ContentId, + pub reporter_user_id: i64, + pub reason: String, + pub status: ReportStatus, + pub created_at: DateTime, + pub resolved_at: Option>, + pub resolver_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct AdminAction { + pub id: i64, + pub admin_user_id: i64, + pub target_type: String, + pub target_id: String, + pub action: String, + pub created_at: DateTime, +} diff --git a/crates/cgcx-crypto/Cargo.toml b/crates/cgcx-crypto/Cargo.toml new file mode 100644 index 0000000..f810bdb --- /dev/null +++ b/crates/cgcx-crypto/Cargo.toml @@ -0,0 +1,14 @@ +[package] +name = "cgcx-crypto" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +rand = "0.8" +blake3 = "1.5" +sodiumoxide = "0.2" +aes-kw = "0.2" +aes = "0.8" +hex = "0.4" +tracing = "0.1" diff --git a/crates/cgcx-crypto/src/lib.rs b/crates/cgcx-crypto/src/lib.rs new file mode 100644 index 0000000..a8943c6 --- /dev/null +++ b/crates/cgcx-crypto/src/lib.rs @@ -0,0 +1,107 @@ +use blake3::Hasher; +use sodiumoxide::crypto::secretstream::xchacha20poly1305; +use std::path::Path; + +pub mod master_key; +pub use master_key::MasterKey; + +const KEY_WRAP_VERSION: u8 = 0x01; + +#[derive(Debug, Clone)] +pub struct ContentKey { + pub key: xchacha20poly1305::Key, +} + +impl ContentKey { + pub fn generate() -> Self { + let key = xchacha20poly1305::gen_key(); + Self { key } + } +} + +pub fn wrap_content_key(content_key: &xchacha20poly1305::Key, master_key: &MasterKey) -> Vec { + let kek: aes_kw::KekAes256 = (*master_key.as_bytes()).into(); + let key_bytes = content_key.as_ref(); + let mut wrapped = vec![0u8; key_bytes.len() + 8]; + kek.wrap(key_bytes, &mut wrapped).expect("AES-KW wrap failed"); + let mut out = vec![KEY_WRAP_VERSION]; + out.extend_from_slice(&wrapped); + out +} + +pub fn unwrap_content_key(wrapped: &[u8], master_key: &MasterKey) -> cgcx_core::Result { + if wrapped.is_empty() || wrapped[0] != KEY_WRAP_VERSION { + return Err(cgcx_core::CgcxError::Crypto("unsupported key wrap version".into())); + } + let kek: aes_kw::KekAes256 = (*master_key.as_bytes()).into(); + let wrapped_key = &wrapped[1..]; + let mut unwrapped = vec![0u8; wrapped_key.len().saturating_sub(8)]; + kek.unwrap(wrapped_key, &mut unwrapped) + .map_err(|e| cgcx_core::CgcxError::Crypto(format!("AES-KW unwrap failed: {:?}", e)))?; + xchacha20poly1305::Key::from_slice(&unwrapped) + .ok_or_else(|| cgcx_core::CgcxError::Crypto("invalid unwrapped key length".into())) +} + +pub struct EncryptStream { + stream: xchacha20poly1305::Stream, + hasher: Hasher, + header: xchacha20poly1305::Header, +} + +impl EncryptStream { + pub fn new(key: &xchacha20poly1305::Key) -> Self { + let (stream, header) = xchacha20poly1305::Stream::init_push(key) + .expect("secretstream init_push failed"); + let mut hasher = Hasher::new(); + hasher.update(header.as_ref()); + Self { stream, hasher, header } + } + + pub fn header(&self) -> &xchacha20poly1305::Header { + &self.header + } + + pub fn push(&mut self, plaintext: &[u8], tag: xchacha20poly1305::Tag) -> Vec { + let ciphertext = self.stream.push(plaintext, None, tag) + .expect("secretstream push failed"); + self.hasher.update(&ciphertext); + ciphertext + } + + pub fn finalize(self) -> [u8; 32] { + self.hasher.finalize().into() + } +} + +pub struct DecryptStream { + stream: xchacha20poly1305::Stream, + hasher: Hasher, +} + +impl DecryptStream { + pub fn new(key: &xchacha20poly1305::Key, header: &xchacha20poly1305::Header) -> cgcx_core::Result { + let stream = xchacha20poly1305::Stream::init_pull(header, key) + .map_err(|_| cgcx_core::CgcxError::Crypto("secretstream init_pull failed".into()))?; + let mut hasher = Hasher::new(); + hasher.update(header.as_ref()); + Ok(Self { stream, hasher }) + } + + pub fn pull(&mut self, ciphertext: &[u8]) -> cgcx_core::Result<(Vec, xchacha20poly1305::Tag)> { + let result = self.stream.pull(ciphertext, None) + .map_err(|_| cgcx_core::CgcxError::Crypto("secretstream pull failed (tampered data?)".into()))?; + self.hasher.update(ciphertext); + Ok(result) + } + + pub fn finalize(self) -> [u8; 32] { + self.hasher.finalize().into() + } +} + +pub fn hash_file_at_path(path: &Path) -> cgcx_core::Result<[u8; 32]> { + let mut hasher = Hasher::new(); + let mut file = std::fs::File::open(path)?; + std::io::copy(&mut file, &mut hasher)?; + Ok(hasher.finalize().into()) +} diff --git a/crates/cgcx-crypto/src/master_key.rs b/crates/cgcx-crypto/src/master_key.rs new file mode 100644 index 0000000..eaef495 --- /dev/null +++ b/crates/cgcx-crypto/src/master_key.rs @@ -0,0 +1,93 @@ +use blake3::Hasher; +use rand::RngCore; +use std::path::Path; +use tracing::{info, trace, warn}; + +pub struct MasterKey([u8; 32]); + +impl MasterKey { + pub fn generate() -> Self { + let mut key = [0u8; 32]; + rand::thread_rng().fill_bytes(&mut key); + Self(key) + } + + pub fn from_hex(hex_str: &str) -> cgcx_core::Result { + let bytes = hex::decode(hex_str.trim()) + .map_err(|e| cgcx_core::CgcxError::Crypto(format!("invalid master key hex: {}", e)))?; + if bytes.len() != 32 { + return Err(cgcx_core::CgcxError::Crypto(format!( + "master key must be 32 bytes (64 hex chars), got {} bytes", + bytes.len() + ))); + } + let mut key = [0u8; 32]; + key.copy_from_slice(&bytes); + Ok(Self(key)) + } + + pub fn to_hex(&self) -> String { + hex::encode(self.0) + } + + pub fn as_bytes(&self) -> &[u8; 32] { + &self.0 + } + + pub fn fingerprint(&self) -> String { + let hash = Hasher::new().update(&self.0).finalize(); + hex::encode(&hash.as_bytes()[..8]) + } + + pub fn load_from_env(var: &str) -> cgcx_core::Result { + sodiumoxide::init().map_err(|_| cgcx_core::CgcxError::Crypto("sodiumoxide init failed".into()))?; + match std::env::var(var) { + Ok(val) => { + let key = Self::from_hex(&val)?; + info!("Master key loaded from env var {}", var); + Ok(key) + } + Err(_) => { + let key = Self::generate(); + warn!( + "Env var {} not set. A new master key has been generated.\n\ + SAVE THIS KEY IMMEDIATELY (64 hex chars):\n{}\n\ + Set it as {}= to persist across restarts.", + var, key.to_hex(), var + ); + Ok(key) + } + } + } + + pub fn load_from_file(path: &Path) -> cgcx_core::Result { + sodiumoxide::init().map_err(|_| cgcx_core::CgcxError::Crypto("sodiumoxide init failed".into()))?; + if path.exists() { + let val = std::fs::read_to_string(path) + .map_err(|e| cgcx_core::CgcxError::Crypto(format!("read key file: {}", e)))?; + let key = Self::from_hex(&val)?; + info!("Master key loaded from file {:?}", path); + Ok(key) + } else { + let key = Self::generate(); + std::fs::write(path, key.to_hex()) + .map_err(|e| cgcx_core::CgcxError::Crypto(format!("write key file: {}", e)))?; + #[cfg(unix)] + { + use std::os::unix::fs::PermissionsExt; + let mut perms = std::fs::metadata(path).unwrap().permissions(); + perms.set_mode(0o600); + std::fs::set_permissions(path, perms).ok(); + } + warn!("Generated new master key and wrote to {:?}", path); + Ok(key) + } + } + + pub fn log_startup(&self, debug_log_keys: bool) { + info!("Storage master key loaded. fingerprint={}", self.fingerprint()); + if debug_log_keys { + trace!("Master key full hex: {}", self.to_hex()); + } + } +} diff --git a/crates/cgcx-db/Cargo.toml b/crates/cgcx-db/Cargo.toml new file mode 100644 index 0000000..af9a53a --- /dev/null +++ b/crates/cgcx-db/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cgcx-db" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +cgcx-config = { path = "../cgcx-config" } +chrono = { version = "0.4", features = ["serde"] } +rusqlite = { version = "0.32", features = ["bundled", "chrono"] } +rusqlite_migration = "1.3" +tokio = { version = "1", features = ["sync", "rt"] } +tracing = "0.1" diff --git a/crates/cgcx-db/src/lib.rs b/crates/cgcx-db/src/lib.rs new file mode 100644 index 0000000..090b362 --- /dev/null +++ b/crates/cgcx-db/src/lib.rs @@ -0,0 +1,55 @@ +use cgcx_core::{Result, CgcxError}; +use rusqlite::Connection; +use std::path::Path; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub mod repos; + +pub use repos::*; + +#[derive(Clone)] +pub struct Database { + conn: Arc>, +} + +impl Database { + pub fn open>(path: P) -> Result { + let conn = Connection::open(path).map_err(|e| CgcxError::Database(e.to_string()))?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000;" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + pub fn open_in_memory() -> Result { + let conn = Connection::open_in_memory().map_err(|e| CgcxError::Database(e.to_string()))?; + conn.execute_batch( + "PRAGMA journal_mode = WAL; + PRAGMA foreign_keys = ON; + PRAGMA busy_timeout = 5000;" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(Self { + conn: Arc::new(Mutex::new(conn)), + }) + } + + pub fn conn(&self) -> Arc> { + self.conn.clone() + } + + pub async fn run_migrations(&self) -> Result<()> { + let mut conn = self.conn.lock().await; + let migrations = rusqlite_migration::Migrations::new(vec![ + rusqlite_migration::M::up(include_str!("../../../migrations/001_init.sql")), + rusqlite_migration::M::up(include_str!("../../../migrations/002_indexes.sql")), + ]); + migrations.to_latest(&mut *conn) + .map_err(|e| CgcxError::Database(format!("migration failed: {}", e)))?; + Ok(()) + } +} diff --git a/crates/cgcx-db/src/repos.rs b/crates/cgcx-db/src/repos.rs new file mode 100644 index 0000000..48a54b2 --- /dev/null +++ b/crates/cgcx-db/src/repos.rs @@ -0,0 +1,389 @@ +use cgcx_core::{AdminAction, Content, ContentFile, ContentId, ContentStatus, Report, ReportStatus, Result, CgcxError, User}; +use rusqlite::{params, OptionalExtension}; +use std::sync::Arc; +use tokio::sync::Mutex; + +pub struct UserRepo { + conn: Arc>, +} + +impl UserRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn ensure_exists(&self, id: i64, username: Option<&str>, first_name: &str) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO users (id, telegram_username, first_name) VALUES (?1, ?2, ?3) + ON CONFLICT(id) DO UPDATE SET telegram_username=excluded.telegram_username, first_name=excluded.first_name", + params![id, username, first_name], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn get(&self, id: i64) -> Result> { + let conn = self.conn.lock().await; + let row = conn.query_row( + "SELECT id, telegram_username, first_name, role, accepted_terms_at, created_at FROM users WHERE id = ?1", + params![id], + |row| { + let role: String = row.get(3)?; + Ok(User { + id: row.get(0)?, + telegram_username: row.get(1)?, + first_name: row.get(2)?, + role: match role.as_str() { + "admin" => cgcx_core::UserRole::Admin, + "banned" => cgcx_core::UserRole::Banned, + _ => cgcx_core::UserRole::User, + }, + accepted_terms_at: row.get(4)?, + created_at: row.get(5)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(row) + } + + pub async fn set_role(&self, id: i64, role: &str) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE users SET role = ?1 WHERE id = ?2", + params![role, id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn set_accepted_terms(&self, id: i64) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "UPDATE users SET accepted_terms_at = datetime('now') WHERE id = ?1", + params![id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } +} + +pub struct ContentRepo { + conn: Arc>, +} + +impl ContentRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn insert(&self, content: &Content) -> Result<()> { + let conn = self.conn.lock().await; + let status = format!("{:?}", content.status).to_lowercase(); + conn.execute( + "INSERT INTO contents (id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9) + ON CONFLICT(id) DO NOTHING", + params![ + content.id.as_str(), + content.user_id, + status, + content.view_count as i64, + content.max_views.map(|v| v as i64), + content.allow_download as i64, + content.password_hash.as_ref(), + content.created_at, + content.deleted_at, + ], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn get(&self, id: &ContentId) -> Result> { + let conn = self.conn.lock().await; + let row = conn.query_row( + "SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at + FROM contents WHERE id = ?1", + params![id.as_str()], + |row| { + let status: String = row.get(2)?; + Ok(Content { + id: ContentId::new_unchecked(row.get(0)?), + user_id: row.get(1)?, + status: match status.as_str() { + "staged" => ContentStatus::Staged, + "deleted" => ContentStatus::Deleted, + "blacklisted" => ContentStatus::Blacklisted, + _ => ContentStatus::Active, + }, + view_count: row.get::<_, i64>(3)? as u64, + max_views: row.get::<_, Option>(4)?.map(|v| v as u64), + allow_download: row.get::<_, i64>(5)? != 0, + password_hash: row.get(6)?, + created_at: row.get(7)?, + deleted_at: row.get(8)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(row) + } + + pub async fn list_by_user(&self, user_id: i64, limit: usize, offset: usize) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, user_id, status, view_count, max_views, allow_download, password_hash, created_at, deleted_at + FROM contents WHERE user_id = ?1 AND status != 'deleted' ORDER BY created_at DESC LIMIT ?2 OFFSET ?3" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map(params![user_id, limit as i64, offset as i64], |row| { + let status: String = row.get(2)?; + Ok(Content { + id: ContentId::new_unchecked(row.get(0)?), + user_id: row.get(1)?, + status: match status.as_str() { + "staged" => ContentStatus::Staged, + "deleted" => ContentStatus::Deleted, + "blacklisted" => ContentStatus::Blacklisted, + _ => ContentStatus::Active, + }, + view_count: row.get::<_, i64>(3)? as u64, + max_views: row.get::<_, Option>(4)?.map(|v| v as u64), + allow_download: row.get::<_, i64>(5)? != 0, + password_hash: row.get(6)?, + created_at: row.get(7)?, + deleted_at: row.get(8)?, + }) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(out) + } + + pub async fn count_by_user(&self, user_id: i64) -> Result { + let conn = self.conn.lock().await; + let count: i64 = conn.query_row( + "SELECT COUNT(*) FROM contents WHERE user_id = ?1 AND status != 'deleted'", + params![user_id], + |row| row.get(0), + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(count as usize) + } + + pub async fn increment_views(&self, id: &ContentId) -> Result { + let conn = self.conn.lock().await; + let new: i64 = conn.query_row( + "UPDATE contents SET view_count = view_count + 1 WHERE id = ?1 RETURNING view_count", + params![id.as_str()], + |row| row.get(0), + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(new as u64) + } + + pub async fn set_status(&self, id: &ContentId, status: ContentStatus) -> Result<()> { + let conn = self.conn.lock().await; + let s = format!("{:?}", status).to_lowercase(); + conn.execute( + "UPDATE contents SET status = ?1, deleted_at = CASE WHEN ?1 IN ('deleted','blacklisted') THEN datetime('now') ELSE NULL END WHERE id = ?2", + params![s, id.as_str()], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn delete_permanent(&self, id: &ContentId) -> Result<()> { + let mut conn = self.conn.lock().await; + let tx = conn.transaction().map_err(|e| CgcxError::Database(e.to_string()))?; + tx.execute("DELETE FROM content_files WHERE content_id = ?1", params![id.as_str()]) + .map_err(|e| CgcxError::Database(e.to_string()))?; + tx.execute("DELETE FROM contents WHERE id = ?1", params![id.as_str()]) + .map_err(|e| CgcxError::Database(e.to_string()))?; + tx.commit().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } +} + +pub struct ContentFileRepo { + conn: Arc>, +} + +impl ContentFileRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn insert(&self, file: &ContentFile) -> Result<()> { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO content_files (content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at) + VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11) + ON CONFLICT(content_id, file_index) DO NOTHING", + params![ + file.content_id.as_str(), + file.file_index as i64, + &file.original_name, + file.stored_path.to_str(), + &file.mime_type, + file.size_bytes as i64, + file.ciphertext_size_bytes as i64, + &file.encrypted_key_wrapped, + &file.encrypted_hash, + file.render_flags as i64, + file.created_at, + ], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } + + pub async fn list_by_content(&self, content_id: &ContentId) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT content_id, file_index, original_name, stored_path, mime_type, size_bytes, ciphertext_size_bytes, encrypted_key_wrapped, encrypted_hash, render_flags, created_at + FROM content_files WHERE content_id = ?1 ORDER BY file_index" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map(params![content_id.as_str()], |row| { + Ok(ContentFile { + content_id: ContentId::new_unchecked(row.get(0)?), + file_index: row.get::<_, i64>(1)? as u32, + original_name: row.get(2)?, + stored_path: std::path::PathBuf::from(row.get::<_, String>(3)?), + mime_type: row.get(4)?, + size_bytes: row.get::<_, i64>(5)? as u64, + ciphertext_size_bytes: row.get::<_, i64>(6)? as u64, + encrypted_key_wrapped: row.get(7)?, + encrypted_hash: row.get(8)?, + render_flags: row.get::<_, i64>(9)? as u32, + created_at: row.get(10)?, + }) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(out) + } + + pub async fn find_orphan_files(&self) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT cf.stored_path FROM content_files cf + JOIN contents c ON c.id = cf.content_id + WHERE c.status IN ('deleted', 'blacklisted')" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map([], |row| { + row.get::<_, String>(0) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(out) + } +} + +pub struct ReportRepo { + conn: Arc>, +} + +impl ReportRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn insert(&self, content_id: &ContentId, reporter_user_id: i64, reason: &str) -> Result { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO reports (content_id, reporter_user_id, reason) VALUES (?1, ?2, ?3)", + params![content_id.as_str(), reporter_user_id, reason], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(conn.last_insert_rowid()) + } + + pub async fn get(&self, id: i64) -> Result> { + let conn = self.conn.lock().await; + let row = conn.query_row( + "SELECT id, content_id, reporter_user_id, reason, status, created_at, resolved_at, resolver_id + FROM reports WHERE id = ?1", + params![id], + |row| { + let status: String = row.get(4)?; + Ok(Report { + id: row.get(0)?, + content_id: ContentId::new_unchecked(row.get(1)?), + reporter_user_id: row.get(2)?, + reason: row.get(3)?, + status: match status.as_str() { + "dismissed" => ReportStatus::Dismissed, + "actioned" => ReportStatus::Actioned, + _ => ReportStatus::Open, + }, + created_at: row.get(5)?, + resolved_at: row.get(6)?, + resolver_id: row.get(7)?, + }) + }, + ).optional().map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(row) + } + + pub async fn list(&self, limit: usize, offset: usize) -> Result> { + let conn = self.conn.lock().await; + let mut stmt = conn.prepare( + "SELECT id, content_id, reporter_user_id, reason, status, created_at, resolved_at, resolver_id + FROM reports ORDER BY created_at DESC LIMIT ?1 OFFSET ?2" + ).map_err(|e| CgcxError::Database(e.to_string()))?; + let rows = stmt.query_map(params![limit as i64, offset as i64], |row| { + let status: String = row.get(4)?; + Ok(Report { + id: row.get(0)?, + content_id: ContentId::new_unchecked(row.get(1)?), + reporter_user_id: row.get(2)?, + reason: row.get(3)?, + status: match status.as_str() { + "dismissed" => ReportStatus::Dismissed, + "actioned" => ReportStatus::Actioned, + _ => ReportStatus::Open, + }, + created_at: row.get(5)?, + resolved_at: row.get(6)?, + resolver_id: row.get(7)?, + }) + }).map_err(|e| CgcxError::Database(e.to_string()))?; + let mut out = Vec::new(); + for r in rows { + out.push(r.map_err(|e| CgcxError::Database(e.to_string()))?); + } + Ok(out) + } + + pub async fn resolve(&self, id: i64, status: ReportStatus, resolver_id: i64) -> Result<()> { + let conn = self.conn.lock().await; + let s = format!("{:?}", status).to_lowercase(); + conn.execute( + "UPDATE reports SET status = ?1, resolver_id = ?2, resolved_at = datetime('now') WHERE id = ?3", + params![s, resolver_id, id], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(()) + } +} + +pub struct AdminActionRepo { + conn: Arc>, +} + +impl AdminActionRepo { + pub fn new(conn: Arc>) -> Self { + Self { conn } + } + + pub async fn insert(&self, action: &AdminAction) -> Result { + let conn = self.conn.lock().await; + conn.execute( + "INSERT INTO admin_actions (admin_user_id, target_type, target_id, action) + VALUES (?1, ?2, ?3, ?4)", + params![ + action.admin_user_id, + &action.target_type, + &action.target_id, + &action.action, + ], + ).map_err(|e| CgcxError::Database(e.to_string()))?; + Ok(conn.last_insert_rowid()) + } +} diff --git a/crates/cgcx-file-pipeline/Cargo.toml b/crates/cgcx-file-pipeline/Cargo.toml new file mode 100644 index 0000000..84276f6 --- /dev/null +++ b/crates/cgcx-file-pipeline/Cargo.toml @@ -0,0 +1,17 @@ +[package] +name = "cgcx-file-pipeline" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +cgcx-crypto = { path = "../cgcx-crypto" } +cgcx-storage = { path = "../cgcx-storage" } +cgcx-content-typing = { path = "../cgcx-content-typing" } +cgcx-db = { path = "../cgcx-db" } +cgcx-config = { path = "../cgcx-config" } +tokio = { version = "1", features = ["fs", "io-util", "sync"] } +tempfile = "3" +tracing = "0.1" +chrono = "0.4" +sodiumoxide = "0.2" diff --git a/crates/cgcx-file-pipeline/src/lib.rs b/crates/cgcx-file-pipeline/src/lib.rs new file mode 100644 index 0000000..a7b0d70 --- /dev/null +++ b/crates/cgcx-file-pipeline/src/lib.rs @@ -0,0 +1,285 @@ +use cgcx_config::Config; +use cgcx_core::{ContentFile, ContentId, ContentStatus, Content, Result, CgcxError}; +use cgcx_crypto::{ContentKey, wrap_content_key}; +use cgcx_db::{Database, ContentRepo, ContentFileRepo}; +use cgcx_storage::Storage; +use cgcx_content_typing::{detect_mime_type, compute_render_flags}; +use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::{Message, Final}; +use tokio::io::{AsyncRead, AsyncReadExt, AsyncWriteExt}; +use std::collections::HashSet; + +pub use cgcx_crypto::MasterKey; + +pub struct FilePipeline { + storage: Storage, + db: Database, + config: Config, +} + +impl FilePipeline { + pub fn new(storage: Storage, db: Database, config: Config) -> Self { + Self { storage, db, config } + } + + pub async fn ingest_file( + &self, + content_id: &ContentId, + file_index: u32, + mut source: impl AsyncRead + Unpin, + original_name: &str, + master_key: &MasterKey, + sem: &tokio::sync::Semaphore, + ) -> Result { + let _permit = sem.acquire().await + .map_err(|e| CgcxError::Storage(format!("semaphore acquire failed: {}", e)))?; + + let chunk_size = self.config.storage.chunk_size_bytes; + let mut buf = vec![0u8; chunk_size]; + + // Read first chunk for MIME detection + let n = source.read(&mut buf).await + .map_err(|e| CgcxError::Storage(format!("read failed: {}", e)))?; + if n == 0 { + return Err(CgcxError::BadRequest("empty file".into())); + } + + let mime_type = detect_mime_type(&buf[..n], original_name); + let render_flags = compute_render_flags(&mime_type, original_name, &buf[..n]); + + let content_key = ContentKey::generate(); + let mut encrypt_stream = cgcx_crypto::EncryptStream::new(&content_key.key); + let header = encrypt_stream.header().clone(); + + let named_temp = self.storage.temp_file()?; + let temp_path = named_temp.path().to_path_buf(); + + let mut total_size: u64 = 0; + { + let mut temp_file = tokio::fs::File::create(&temp_path).await + .map_err(|e| CgcxError::Storage(format!("create temp file: {}", e)))?; + + temp_file.write_all(header.as_ref()).await + .map_err(|e| CgcxError::Storage(format!("write header: {}", e)))?; + + let mut pending = n; + + loop { + if pending == chunk_size { + let new_total = total_size + pending as u64; + if new_total > self.config.upload_limits.max_file_size_bytes { + return Err(CgcxError::BadRequest(format!( + "file too large: {} > {}", + new_total, self.config.upload_limits.max_file_size_bytes + ))); + } + total_size = new_total; + let ciphertext = encrypt_stream.push(&buf[..pending], Message); + temp_file.write_all(&(ciphertext.len() as u32).to_le_bytes()).await + .map_err(|e| CgcxError::Storage(format!("write length prefix: {}", e)))?; + temp_file.write_all(&ciphertext).await + .map_err(|e| CgcxError::Storage(format!("write ciphertext: {}", e)))?; + pending = 0; + } + + let read_n = source.read(&mut buf[pending..]).await + .map_err(|e| CgcxError::Storage(format!("read failed: {}", e)))?; + + if read_n == 0 { + if pending > 0 { + let new_total = total_size + pending as u64; + if new_total > self.config.upload_limits.max_file_size_bytes { + return Err(CgcxError::BadRequest(format!( + "file too large: {} > {}", + new_total, self.config.upload_limits.max_file_size_bytes + ))); + } + total_size = new_total; + let ciphertext = encrypt_stream.push(&buf[..pending], Final); + temp_file.write_all(&(ciphertext.len() as u32).to_le_bytes()).await + .map_err(|e| CgcxError::Storage(format!("write length prefix: {}", e)))?; + temp_file.write_all(&ciphertext).await + .map_err(|e| CgcxError::Storage(format!("write ciphertext: {}", e)))?; + } else if total_size > 0 { + // File ended exactly on a chunk boundary; push empty final tag. + let ciphertext = encrypt_stream.push(&[], Final); + temp_file.write_all(&(ciphertext.len() as u32).to_le_bytes()).await + .map_err(|e| CgcxError::Storage(format!("write length prefix: {}", e)))?; + temp_file.write_all(&ciphertext).await + .map_err(|e| CgcxError::Storage(format!("write ciphertext: {}", e)))?; + } + break; + } + + pending += read_n; + } + + temp_file.flush().await + .map_err(|e| CgcxError::Storage(format!("flush temp file: {}", e)))?; + } + + let encrypted_hash = encrypt_stream.finalize(); + let ciphertext_size_bytes = self.storage.file_size(&temp_path).await?; + + let final_path = self.storage.file_path(content_id, file_index, &mime_type)?; + if let Some(parent) = final_path.parent() { + tokio::fs::create_dir_all(parent).await + .map_err(|e| CgcxError::Storage(e.to_string()))?; + } + + named_temp.persist(&final_path) + .map_err(|e| CgcxError::Storage(format!("persist failed: {}", e)))?; + + let encrypted_key_wrapped = wrap_content_key(&content_key.key, master_key); + + let content_file = ContentFile { + content_id: content_id.clone(), + file_index, + original_name: original_name.to_string(), + stored_path: final_path, + mime_type, + size_bytes: total_size, + ciphertext_size_bytes, + encrypted_key_wrapped, + encrypted_hash: encrypted_hash.to_vec(), + render_flags, + created_at: chrono::Utc::now(), + }; + + let file_repo = ContentFileRepo::new(self.db.conn()); + file_repo.insert(&content_file).await?; + + Ok(content_file) + } + + pub async fn create_content_entry( + &self, + content_id: ContentId, + user_id: i64, + max_views: Option, + allow_download: bool, + password_hash: Option, + ) -> Result<()> { + let content = Content { + id: content_id, + user_id, + status: ContentStatus::Staged, + view_count: 0, + max_views, + allow_download, + password_hash, + created_at: chrono::Utc::now(), + deleted_at: None, + }; + let repo = ContentRepo::new(self.db.conn()); + repo.insert(&content).await + } + + pub async fn activate_content(&self, content_id: &ContentId) -> Result<()> { + let repo = ContentRepo::new(self.db.conn()); + repo.set_status(content_id, ContentStatus::Active).await + } + + pub async fn delete_content(&self, content_id: &ContentId, keep_disk: bool) -> Result<()> { + let file_repo = ContentFileRepo::new(self.db.conn()); + let files = file_repo.list_by_content(content_id).await?; + + if !keep_disk { + for file in &files { + if let Err(e) = tokio::fs::remove_file(&file.stored_path).await { + tracing::warn!("failed to remove file {:?}: {}", file.stored_path, e); + } + } + if let Some(first) = files.first() { + let _ = self.storage.delete_content_files(content_id, &first.mime_type).await; + } + } + + let repo = ContentRepo::new(self.db.conn()); + repo.delete_permanent(content_id).await + } + + pub async fn cleanup_orphans(&self) -> Result<()> { + let cutoff = std::time::SystemTime::now() - std::time::Duration::from_secs(24 * 60 * 60); + + // 1. Clean old temp files + let mut entries = tokio::fs::read_dir(self.storage.temp_dir()).await + .map_err(|e| CgcxError::Storage(format!("read temp dir: {}", e)))?; + while let Some(entry) = entries.next_entry().await + .map_err(|e| CgcxError::Storage(format!("read temp dir entry: {}", e)))? + { + let path = entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("tmp") { + if let Ok(meta) = entry.metadata().await { + if let Ok(modified) = meta.modified() { + if modified < cutoff { + if let Err(e) = tokio::fs::remove_file(&path).await { + tracing::warn!("failed to remove orphan temp file {:?}: {}", path, e); + } else { + tracing::info!("removed orphan temp file: {:?}", path); + } + } + } + } + } + } + + // 2. Clean unreferenced .enc files in storage dirs + let file_repo = ContentFileRepo::new(self.db.conn()); + + for root in [self.storage.media_dir(), self.storage.documents_dir(), self.storage.text_dir()] { + let mut entries = match tokio::fs::read_dir(root).await { + Ok(e) => e, + Err(e) => { + tracing::warn!("failed to read storage dir {:?}: {}", root, e); + continue; + } + }; + + while let Some(entry) = entries.next_entry().await + .map_err(|e| CgcxError::Storage(format!("read storage dir entry: {}", e)))? + { + let dir_path = entry.path(); + if !dir_path.is_dir() { + continue; + } + + let content_id_str = dir_path.file_name() + .and_then(|s| s.to_str()) + .unwrap_or(""); + + let db_paths: HashSet = if ContentId::is_valid(content_id_str) { + let content_id = ContentId::new_unchecked(content_id_str.to_string()); + match file_repo.list_by_content(&content_id).await { + Ok(files) => files.into_iter().map(|f| f.stored_path).collect(), + Err(e) => { + tracing::warn!("failed to list files for {}: {}", content_id, e); + continue; + } + } + } else { + // Invalid content directory – nothing in it can be referenced. + HashSet::new() + }; + + let mut sub_entries = tokio::fs::read_dir(&dir_path).await + .map_err(|e| CgcxError::Storage(format!("read content dir: {}", e)))?; + while let Some(sub_entry) = sub_entries.next_entry().await + .map_err(|e| CgcxError::Storage(format!("read content dir entry: {}", e)))? + { + let path = sub_entry.path(); + if path.extension().and_then(|s| s.to_str()) == Some("enc") { + if !db_paths.contains(&path) { + if let Err(e) = tokio::fs::remove_file(&path).await { + tracing::warn!("failed to remove orphan enc file {:?}: {}", path, e); + } else { + tracing::info!("removed orphan enc file: {:?}", path); + } + } + } + } + } + } + + Ok(()) + } +} diff --git a/crates/cgcx-moderation/Cargo.toml b/crates/cgcx-moderation/Cargo.toml new file mode 100644 index 0000000..b6975a4 --- /dev/null +++ b/crates/cgcx-moderation/Cargo.toml @@ -0,0 +1,13 @@ +[package] +name = "cgcx-moderation" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +cgcx-config = { path = "../cgcx-config" } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +tokio = { version = "1", features = ["fs", "sync", "time", "rt"] } +tracing = "0.1" +chrono = "0.4" diff --git a/crates/cgcx-moderation/src/lib.rs b/crates/cgcx-moderation/src/lib.rs new file mode 100644 index 0000000..0146e8f --- /dev/null +++ b/crates/cgcx-moderation/src/lib.rs @@ -0,0 +1,152 @@ +use cgcx_config::{Config, ShareMode}; +use cgcx_core::Result; +use serde::{Deserialize, Serialize}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; +use std::sync::Arc; +use tracing::{info, warn}; + +#[derive(Debug, Clone, Default, Deserialize, Serialize)] +pub struct ModerationLists { + pub blacklisted: HashSet, + pub whitelisted: HashSet, +} + +pub struct ModerationEngine { + lists: Arc>, + share_mode: ShareMode, + blacklist_path: PathBuf, + whitelist_path: PathBuf, +} + +impl ModerationEngine { + pub fn new(config: &Config, base_data_dir: PathBuf) -> Self { + Self { + lists: Arc::new(std::sync::RwLock::new(ModerationLists::default())), + share_mode: config.content.share_mode.clone(), + blacklist_path: base_data_dir.join("blacklisted_ids.json"), + whitelist_path: base_data_dir.join("whitelisted_ids.json"), + } + } + + pub async fn load(&self) -> Result<()> { + let blacklisted = load_id_set(&self.blacklist_path).await?; + let whitelisted = load_id_set(&self.whitelist_path).await?; + let mut lists = self.lists.write().unwrap(); + *lists = ModerationLists { blacklisted, whitelisted }; + info!( + "Moderation lists loaded: {} blacklisted, {} whitelisted", + lists.blacklisted.len(), + lists.whitelisted.len() + ); + Ok(()) + } + + pub async fn is_allowed(&self, user_id: i64) -> bool { + let lists = self.lists.read().unwrap(); + match self.share_mode { + ShareMode::B => !lists.blacklisted.contains(&user_id), + ShareMode::W => lists.whitelisted.contains(&user_id), + } + } + + pub async fn blacklist(&self, user_id: i64) -> Result<()> { + { + let mut lists = self.lists.write().unwrap(); + lists.blacklisted.insert(user_id); + } + self.save_blacklist().await?; + Ok(()) + } + + pub async fn whitelist(&self, user_id: i64) -> Result<()> { + { + let mut lists = self.lists.write().unwrap(); + lists.whitelisted.insert(user_id); + } + self.save_whitelist().await?; + Ok(()) + } + + pub async fn remove_blacklist(&self, user_id: i64) -> Result<()> { + { + let mut lists = self.lists.write().unwrap(); + lists.blacklisted.remove(&user_id); + } + self.save_blacklist().await?; + Ok(()) + } + + pub async fn remove_whitelist(&self, user_id: i64) -> Result<()> { + { + let mut lists = self.lists.write().unwrap(); + lists.whitelisted.remove(&user_id); + } + self.save_whitelist().await?; + Ok(()) + } + + async fn save_blacklist(&self) -> Result<()> { + let ids = { + let lists = self.lists.read().unwrap(); + lists.blacklisted.iter().copied().collect() + }; + let data = IdListFile { + ids, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + let json = serde_json::to_string_pretty(&data) + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; + tokio::fs::write(&self.blacklist_path, json) + .await + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; + Ok(()) + } + + async fn save_whitelist(&self) -> Result<()> { + let ids = { + let lists = self.lists.read().unwrap(); + lists.whitelisted.iter().copied().collect() + }; + let data = IdListFile { + ids, + updated_at: chrono::Utc::now().to_rfc3339(), + }; + let json = serde_json::to_string_pretty(&data) + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; + tokio::fs::write(&self.whitelist_path, json) + .await + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; + Ok(()) + } + + pub fn spawn_reload_task(self: Arc) { + tokio::spawn(async move { + let mut interval = tokio::time::interval(std::time::Duration::from_secs(30)); + loop { + interval.tick().await; + if let Err(e) = self.load().await { + warn!("Moderation list reload failed: {}", e); + } + } + }); + } +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct IdListFile { + ids: Vec, + updated_at: String, +} + +async fn load_id_set(path: &Path) -> Result> { + if !path.exists() { + return Ok(HashSet::new()); + } + let json = tokio::fs::read_to_string(path) + .await + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; + let file: IdListFile = serde_json::from_str(&json) + .map_err(|e| cgcx_core::CgcxError::Moderation(e.to_string()))?; + Ok(file.ids.into_iter().collect()) +} diff --git a/crates/cgcx-server/Cargo.toml b/crates/cgcx-server/Cargo.toml new file mode 100644 index 0000000..a1cc94e --- /dev/null +++ b/crates/cgcx-server/Cargo.toml @@ -0,0 +1,38 @@ +[package] +name = "cgcx-server" +version.workspace = true +edition.workspace = true + +[[bin]] +name = "cgcx-server" +path = "src/main.rs" + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +cgcx-config = { path = "../cgcx-config" } +cgcx-db = { path = "../cgcx-db" } +cgcx-storage = { path = "../cgcx-storage" } +cgcx-crypto = { path = "../cgcx-crypto" } +cgcx-content-typing = { path = "../cgcx-content-typing" } +cgcx-moderation = { path = "../cgcx-moderation" } +cgcx-file-pipeline = { path = "../cgcx-file-pipeline" } +axum = { version = "0.7", features = ["macros"] } +tower-http = { version = "0.6", features = ["fs", "trace", "cors", "compression-gzip", "catch-panic", "timeout"] } +tokio = { version = "1", features = ["rt-multi-thread", "macros", "fs", "sync"] } +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" +chrono = { version = "0.4", features = ["serde"] } +tower = "0.5" +base64 = "0.21" +hex = "0.4" +tokio-stream = "0.1" +blake3 = "1.5" +sodiumoxide = "0.2" +tower_governor = "0.5" +argon2 = "0.5" +password-hash = "0.5" +hmac = "0.12" +sha2 = "0.10" +subtle = "2.5" diff --git a/crates/cgcx-server/src/lib.rs b/crates/cgcx-server/src/lib.rs new file mode 100644 index 0000000..5a4a774 --- /dev/null +++ b/crates/cgcx-server/src/lib.rs @@ -0,0 +1 @@ +pub fn placeholder() {} diff --git a/crates/cgcx-server/src/main.rs b/crates/cgcx-server/src/main.rs new file mode 100644 index 0000000..39c0ee1 --- /dev/null +++ b/crates/cgcx-server/src/main.rs @@ -0,0 +1,713 @@ +use axum::{ + body::Body, + extract::{Path, Query, State}, + http::{header, HeaderMap, HeaderName, HeaderValue, Method, StatusCode}, + middleware::Next, + response::{IntoResponse, Response}, + routing::{get, post}, + Json, Router, +}; +use cgcx_config::Config; +use cgcx_core::{ContentId, CgcxError}; +use cgcx_crypto::{unwrap_content_key, DecryptStream, MasterKey}; +use cgcx_db::{Database, ContentRepo, ContentFileRepo}; +use cgcx_storage::Storage; +use serde::{Deserialize, Serialize}; +use std::sync::Arc; +use std::time::Duration; +use tokio::io::AsyncReadExt; +use tower_http::{ + catch_panic::CatchPanicLayer, + compression::CompressionLayer, + cors::{AllowOrigin, CorsLayer}, + services::{ServeDir, ServeFile}, + timeout::TimeoutLayer, + trace::TraceLayer, +}; +use tracing::{info, warn}; +use sodiumoxide::crypto::secretstream::xchacha20poly1305::Tag::Final as TagFinal; + +#[derive(Clone)] +struct AppState { + db: Arc, + storage: Arc, + config: Arc, + master_key: Arc, + cookie_secret: Vec, + allowed_roots: Arc>, +} + +#[derive(Serialize)] +struct HealthResponse { + status: String, +} + +#[derive(Serialize)] +struct ContentMetadata { + cxid: String, + files: Vec, + has_password: bool, + max_views: Option, + current_views: u64, + allow_download: bool, + created_at: String, +} + +#[derive(Serialize)] +struct FileMetadata { + idx: u32, + name: String, + mime: String, + size: u64, + render_flags: u32, +} + +#[derive(Deserialize)] +struct VerifyPasswordRequest { + password: String, +} + +#[derive(Deserialize)] +struct FileQuery { + #[serde(default)] + download: bool, +} + +struct ByteRange { + start: u64, + end: Option, +} + +struct AppError(CgcxError); + +impl From for AppError { + fn from(e: CgcxError) -> Self { + Self(e) + } +} + +impl IntoResponse for AppError { + fn into_response(self) -> Response { + let (status, msg) = match self.0 { + CgcxError::NotFound => (StatusCode::NOT_FOUND, "Not found"), + CgcxError::Unauthorized => (StatusCode::UNAUTHORIZED, "Unauthorized"), + CgcxError::Forbidden => (StatusCode::FORBIDDEN, "Forbidden"), + CgcxError::BadRequest(ref m) => (StatusCode::BAD_REQUEST, m.as_str()), + CgcxError::RateLimited => (StatusCode::TOO_MANY_REQUESTS, "Rate limited"), + CgcxError::InsufficientStorage => (StatusCode::INSUFFICIENT_STORAGE, "Insufficient storage"), + _ => (StatusCode::INTERNAL_SERVER_ERROR, "Internal error"), + }; + (status, msg.to_string()).into_response() + } +} + +type AppResult = Result; + +#[tokio::main] +async fn main() -> cgcx_core::Result<()> { + tracing_subscriber::fmt::init(); + + let config = Arc::new(Config::load()?); + config.validate()?; + + let db = Arc::new(Database::open("data/db.sqlite")?); + db.run_migrations().await?; + + let storage = Arc::new(Storage::new(config.storage.paths.clone())); + storage.ensure_dirs().await?; + + let master_key = match &config.crypto.aes_master_key_source { + cgcx_config::KeySource::Env { var } => MasterKey::load_from_env(var)?, + cgcx_config::KeySource::File { path } => MasterKey::load_from_file(path)?, + }; + master_key.log_startup(false); + + let cookie_secret = blake3::hash(master_key.as_bytes()).as_bytes().to_vec(); + + let allowed_roots = Arc::new(vec![ + tokio::fs::canonicalize(&config.storage.paths.media).await.map_err(|e| CgcxError::Io(e))?, + tokio::fs::canonicalize(&config.storage.paths.documents).await.map_err(|e| CgcxError::Io(e))?, + tokio::fs::canonicalize(&config.storage.paths.text).await.map_err(|e| CgcxError::Io(e))?, + tokio::fs::canonicalize(&config.storage.paths.temp).await.map_err(|e| CgcxError::Io(e))?, + ]); + + let state = AppState { + db, + storage, + config: config.clone(), + master_key: Arc::new(master_key), + cookie_secret, + allowed_roots, + }; + + let governor_conf = tower_governor::governor::GovernorConfigBuilder::default() + .period(Duration::from_secs(60) / config.rate_limiting.requests_per_minute) + .burst_size(config.rate_limiting.burst) + .finish() + .expect("invalid general rate limit config"); + + let password_governor_conf = tower_governor::governor::GovernorConfigBuilder::default() + .period(Duration::from_secs(60) / config.rate_limiting.password_attempts_per_minute) + .burst_size(3) + .finish() + .expect("invalid password rate limit config"); + + let password_route = Router::new() + .route("/api/content/{cxid}/verify-password", post(verify_password)) + .layer(tower_governor::GovernorLayer { + config: Arc::new(password_governor_conf), + }); + + let static_service = ServeDir::new("frontend/dist") + .fallback(ServeFile::new("frontend/dist/index.html")); + + let base_url = config.server.base_url.clone(); + let cors = CorsLayer::new() + .allow_origin(AllowOrigin::predicate(move |origin: &HeaderValue, _request_parts: &_| { + if let Ok(origin_str) = origin.to_str() { + if origin_str == base_url { + return true; + } + // Allow localhost origins for development + if origin_str.starts_with("http://127.0.0.1:") + || origin_str.starts_with("http://localhost:") + || origin_str.starts_with("https://127.0.0.1:") + || origin_str.starts_with("https://localhost:") + { + return true; + } + } + false + })) + .allow_methods([Method::GET, Method::POST, Method::HEAD, Method::OPTIONS]) + .allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION, header::ACCEPT, header::ACCEPT_ENCODING, header::RANGE]) + .allow_credentials(true) + .max_age(Duration::from_secs(86400)); + + let compression = CompressionLayer::new().compress_when(|_status: axum::http::StatusCode, _version: axum::http::Version, headers: &axum::http::HeaderMap, _extensions: &axum::http::Extensions| { + headers + .get(header::CONTENT_TYPE) + .and_then(|v| v.to_str().ok()) + .map(|ct| { + ct.starts_with("text/html") + || ct.starts_with("text/css") + || ct.starts_with("application/json") + || ct.starts_with("text/plain") + }) + .unwrap_or(false) + }); + + let app = Router::new() + .route("/api/health", get(health)) + .route("/api/content/{cxid}", get(get_metadata)) + .route("/api/content/{cxid}/file/{file_idx}", get(serve_file)) + .merge(password_route) + .fallback_service(static_service) + .layer(tower_governor::GovernorLayer { + config: Arc::new(governor_conf), + }) + .layer(compression) + .layer(cors) + .layer(axum::middleware::from_fn(security_headers)) + .layer(TraceLayer::new_for_http()) + .layer(TimeoutLayer::with_status_code( + StatusCode::REQUEST_TIMEOUT, + Duration::from_secs(30), + )) + .layer(CatchPanicLayer::new()) + .with_state(state.clone()); + + // Spawn background sweeper task + let db_clone = state.db.clone(); + let storage_clone = state.storage.clone(); + let config_clone = (*state.config).clone(); + tokio::spawn(async move { + let mut interval = tokio::time::interval(Duration::from_secs(24 * 60 * 60)); + interval.tick().await; // skip immediate first tick + loop { + interval.tick().await; + info!("Running daily orphan cleanup"); + let pipeline = cgcx_file_pipeline::FilePipeline::new( + (*storage_clone).clone(), + (*db_clone).clone(), + config_clone.clone(), + ); + if let Err(e) = pipeline.cleanup_orphans().await { + warn!("Orphan cleanup failed: {}", e); + } + } + }); + + let addr = format!("{}:{}", config.server.bind_address, config.server.port); + info!("Server listening on http://{}", addr); + let listener = tokio::net::TcpListener::bind(&addr).await.map_err(|e| CgcxError::Io(e))?; + axum::serve(listener, app).await.map_err(|e| CgcxError::Io(e))?; + + Ok(()) +} + +async fn security_headers(req: axum::http::Request, next: Next) -> Response { + let mut response = next.run(req).await; + let headers = response.headers_mut(); + headers.insert( + header::CONTENT_SECURITY_POLICY, + HeaderValue::from_static("default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; media-src 'self' blob:; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';"), + ); + headers.insert(header::X_CONTENT_TYPE_OPTIONS, HeaderValue::from_static("nosniff")); + headers.insert(header::X_FRAME_OPTIONS, HeaderValue::from_static("DENY")); + headers.insert(header::REFERRER_POLICY, HeaderValue::from_static("strict-origin-when-cross-origin")); + headers.insert( + HeaderName::from_static("permissions-policy"), + HeaderValue::from_static("accelerometer=(), camera=(), geolocation=(), gyroscope=(), magnetometer=(), microphone=(), payment=(), usb=()"), + ); + headers.insert( + header::STRICT_TRANSPORT_SECURITY, + HeaderValue::from_static("max-age=31536000; includeSubDomains; preload"), + ); + response +} + +async fn health() -> impl IntoResponse { + axum::Json(HealthResponse { + status: "ok".into(), + }) +} + +async fn get_metadata( + State(state): State, + Path(cxid): Path, + headers: HeaderMap, +) -> AppResult { + let content_id = ContentId::try_from(cxid.as_str())?; + let repo = ContentRepo::new(state.db.conn()); + let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?; + + if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted { + return Err(CgcxError::NotFound.into()); + } + + if let Some(max) = content.max_views { + if content.view_count >= max { + return Ok(Response::builder() + .status(StatusCode::GONE) + .body(Body::empty()) + .unwrap()); + } + } + + if content.password_hash.is_some() { + let cookie_valid = headers + .get_all(header::COOKIE) + .iter() + .any(|v| { + v.to_str().ok().map(|s| { + s.split(';').any(|part| { + let part = part.trim(); + part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret) + }) + }).unwrap_or(false) + }); + if !cookie_valid { + return Err(CgcxError::Unauthorized.into()); + } + } + + let file_repo = ContentFileRepo::new(state.db.conn()); + let files = file_repo.list_by_content(&content_id).await?; + + let body = serde_json::to_vec(&ContentMetadata { + cxid: content.id.to_string(), + files: files.into_iter().map(|f| FileMetadata { + idx: f.file_index, + name: f.original_name, + mime: f.mime_type, + size: f.size_bytes, + render_flags: f.render_flags, + }).collect(), + has_password: content.password_hash.is_some(), + max_views: content.max_views, + current_views: content.view_count, + allow_download: content.allow_download, + created_at: content.created_at.to_rfc3339(), + }).map_err(|e| CgcxError::BadRequest(format!("json serialization: {}", e)))?; + Ok(Response::builder() + .status(StatusCode::OK) + .header(header::CONTENT_TYPE, "application/json") + .body(Body::from(body)) + .unwrap()) +} + +async fn verify_password( + State(state): State, + Path(cxid): Path, + Json(req): Json, +) -> AppResult { + let content_id = ContentId::try_from(cxid.as_str())?; + let repo = ContentRepo::new(state.db.conn()); + let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?; + + let Some(hash) = content.password_hash else { + return Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .body(Body::empty()) + .unwrap()); + }; + + use argon2::{Argon2, PasswordHash, PasswordVerifier}; + let parsed_hash = PasswordHash::new(&hash) + .map_err(|_| CgcxError::Crypto("invalid stored password hash".into()))?; + let valid = Argon2::default() + .verify_password(req.password.as_bytes(), &parsed_hash) + .is_ok(); + if !valid { + return Err(CgcxError::Unauthorized.into()); + } + + let cookie_value = make_cookie_value(&cxid, &state.cookie_secret); + let cookie = format!( + "__Host-pw={}; Max-Age=3600; SameSite=Strict; Secure; HttpOnly; Path=/", + cookie_value + ); + + Ok(Response::builder() + .status(StatusCode::NO_CONTENT) + .header(header::SET_COOKIE, cookie) + .body(Body::empty()) + .unwrap()) +} + +async fn serve_file( + State(state): State, + Path((cxid, file_idx)): Path<(String, u32)>, + Query(query): Query, + headers: HeaderMap, +) -> AppResult { + let content_id = ContentId::try_from(cxid.as_str())?; + let repo = ContentRepo::new(state.db.conn()); + let content = repo.get(&content_id).await?.ok_or(CgcxError::NotFound)?; + + if content.status == cgcx_core::ContentStatus::Deleted || content.status == cgcx_core::ContentStatus::Blacklisted { + return Err(CgcxError::NotFound.into()); + } + + if let Some(max) = content.max_views { + if content.view_count >= max { + return Ok(Response::builder() + .status(StatusCode::GONE) + .body(Body::empty()) + .unwrap()); + } + } + + if content.password_hash.is_some() { + let cookie_valid = headers + .get_all(header::COOKIE) + .iter() + .any(|v| { + v.to_str().ok().map(|s| { + s.split(';').any(|part| { + let part = part.trim(); + part.starts_with("__Host-pw=") && verify_cookie(&cxid, &part[10..], &state.cookie_secret) + }) + }).unwrap_or(false) + }); + if !cookie_valid { + return Err(CgcxError::Unauthorized.into()); + } + } + + if query.download && !content.allow_download { + return Err(CgcxError::Forbidden.into()); + } + + let file_repo = ContentFileRepo::new(state.db.conn()); + let files = file_repo.list_by_content(&content_id).await?; + let file = files.iter().find(|f| f.file_index == file_idx).ok_or(CgcxError::NotFound)?; + + // Path traversal validation + let canonical_path = tokio::fs::canonicalize(&file.stored_path).await + .map_err(|e| { + tracing::error!("canonicalize failed for {:?}: {}", file.stored_path, e); + CgcxError::Storage("invalid stored path".into()) + })?; + if !state.allowed_roots.iter().any(|root| canonical_path.starts_with(root)) { + tracing::error!("Path traversal blocked: {:?}", canonical_path); + return Err(CgcxError::Forbidden.into()); + } + + let etag = format!("\"{}\"", hex::encode(&file.encrypted_hash)); + + // If-None-Match check (skip increment) + if let Some(inm) = headers.get(header::IF_NONE_MATCH) { + if inm.to_str().ok().map(|s| s == etag).unwrap_or(false) { + return Ok(Response::builder() + .status(StatusCode::NOT_MODIFIED) + .header(header::ETAG, etag.clone()) + .body(Body::empty()) + .unwrap()); + } + } + + // Parse Range header + let range = if let Some(range_hdr) = headers.get(header::RANGE) { + if let Some(hdr_str) = range_hdr.to_str().ok() { + match parse_range(hdr_str, file.size_bytes) { + Some(r) => Some(r), + None => { + return Ok(Response::builder() + .status(StatusCode::RANGE_NOT_SATISFIABLE) + .header(header::CONTENT_RANGE, format!("bytes */{}", file.size_bytes)) + .body(Body::empty()) + .unwrap()); + } + } + } else { + None + } + } else { + None + }; + + let is_range = range.is_some(); + let is_conditional = headers.contains_key(header::IF_NONE_MATCH); + if !is_range && !is_conditional { + let new_views = repo.increment_views(&content_id).await?; + if let Some(max) = content.max_views { + if new_views >= max { + if !state.config.content.keep_content { + for f in &files { + if let Err(e) = tokio::fs::remove_file(&f.stored_path).await { + tracing::warn!("failed to remove file {:?}: {}", f.stored_path, e); + } + } + let _ = state.storage.delete_content_files(&content_id, "application/octet-stream").await; + } + repo.set_status(&content_id, cgcx_core::ContentStatus::Deleted).await?; + return Ok(Response::builder() + .status(StatusCode::GONE) + .body(Body::empty()) + .unwrap()); + } + } + } + + let content_type = file.mime_type.clone(); + let sanitized_name = sanitize_content_disposition(&file.original_name); + let disposition = if query.download && content.allow_download { + format!("attachment; filename=\"{}\"", sanitized_name) + } else { + format!("inline; filename=\"{}\"", sanitized_name) + }; + + let (status, content_length, content_range) = if let Some(ref r) = range { + let end = r.end.unwrap_or(file.size_bytes - 1); + let len = end - r.start + 1; + let cr = format!("bytes {}-{}/{}", r.start, end, file.size_bytes); + (StatusCode::PARTIAL_CONTENT, len, Some(cr)) + } else { + (StatusCode::OK, file.size_bytes, None) + }; + + let mut response = Response::builder() + .status(status) + .header(header::CONTENT_TYPE, content_type) + .header(header::CONTENT_DISPOSITION, disposition) + .header(header::ETAG, etag.clone()) + .header(header::CONTENT_LENGTH, content_length.to_string()); + + if file.mime_type.starts_with("video/") || file.mime_type.starts_with("audio/") { + response = response.header(header::ACCEPT_RANGES, "bytes"); + } + + if let Some(cr) = content_range { + response = response.header(header::CONTENT_RANGE, cr); + } + + if content.password_hash.is_some() { + response = response.header(header::CACHE_CONTROL, "private, no-store, max-age=0"); + } else { + response = response.header(header::CACHE_CONTROL, "private, max-age=60"); + } + + let (tx, rx) = tokio::sync::mpsc::channel::, std::io::Error>>(4); + + let path = file.stored_path.clone(); + let master_key = state.master_key.clone(); + let wrapped_key = file.encrypted_key_wrapped.clone(); + let expected_hash = file.encrypted_hash.clone(); + let file_size = file.size_bytes; + + tokio::spawn(async move { + if let Err(e) = stream_decrypted_file(path, master_key, wrapped_key, tx, range, file_size, expected_hash).await { + warn!("stream error: {}", e); + } + }); + + let body_stream = tokio_stream::wrappers::ReceiverStream::new(rx); + let body = Body::from_stream(body_stream); + + Ok(response.body(body).unwrap()) +} + +async fn stream_decrypted_file( + path: std::path::PathBuf, + master_key: Arc, + wrapped_key: Vec, + tx: tokio::sync::mpsc::Sender, std::io::Error>>, + _range: Option, + _file_size: u64, + expected_hash: Vec, +) -> cgcx_core::Result<()> { + let mut file = tokio::fs::File::open(&path).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + let mut header_buf = [0u8; 24]; + file.read_exact(&mut header_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + + let content_key = unwrap_content_key(&wrapped_key, &master_key)?; + let header = sodiumoxide::crypto::secretstream::xchacha20poly1305::Header::from_slice(&header_buf) + .ok_or_else(|| CgcxError::Crypto("invalid header".into()))?; + let mut decrypt_stream = DecryptStream::new(&content_key, &header)?; + + let mut len_buf = [0u8; 4]; + let mut saw_final = false; + loop { + if file.read_exact(&mut len_buf).await.is_err() { + break; // EOF at message boundary + } + let msg_len = u32::from_le_bytes(len_buf) as usize; + let mut msg_buf = vec![0u8; msg_len]; + file.read_exact(&mut msg_buf).await.map_err(|e| CgcxError::Storage(e.to_string()))?; + + match decrypt_stream.pull(&msg_buf) { + Ok((plaintext, tag)) => { + if tx.send(Ok(plaintext)).await.is_err() { + return Ok(()); // client disconnected + } + if tag == TagFinal { + saw_final = true; + break; + } + } + Err(e) => { + let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, e.to_string()))).await; + return Err(e); + } + } + } + + if !saw_final { + let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "stream ended without Final tag"))).await; + return Err(CgcxError::Crypto("stream ended without Final tag".into())); + } + + let computed_hash = decrypt_stream.finalize().to_vec(); + if computed_hash != expected_hash { + tracing::error!(target: "critical", "BLAKE3 integrity mismatch for file {:?}: expected {} got {}", path, hex::encode(&expected_hash), hex::encode(&computed_hash)); + let _ = tx.send(Err(std::io::Error::new(std::io::ErrorKind::InvalidData, "integrity check failed"))).await; + return Err(CgcxError::Crypto("BLAKE3 integrity mismatch".into())); + } + + Ok(()) +} + +fn parse_range(range_hdr: &str, file_size: u64) -> Option { + const PREFIX: &str = "bytes="; + if !range_hdr.starts_with(PREFIX) { + return None; + } + let rest = &range_hdr[PREFIX.len()..]; + // Basic version: only single-byte range + if rest.contains(',') { + return None; + } + let mut parts = rest.splitn(2, '-'); + let start_str = parts.next()?.trim(); + let end_str = parts.next()?.trim(); + + if start_str.is_empty() && end_str.is_empty() { + return None; + } + + if start_str.is_empty() { + let suffix_len: u64 = end_str.parse().ok()?; + let start = file_size.saturating_sub(suffix_len); + Some(ByteRange { + start, + end: Some(file_size.saturating_sub(1)), + }) + } else if end_str.is_empty() { + let start: u64 = start_str.parse().ok()?; + if start >= file_size { + return None; + } + Some(ByteRange { start, end: None }) + } else { + let start: u64 = start_str.parse().ok()?; + let end: u64 = end_str.parse().ok()?; + if start > end || start >= file_size { + return None; + } + let end = end.min(file_size - 1); + Some(ByteRange { start, end: Some(end) }) + } +} + +fn sanitize_content_disposition(name: &str) -> String { + name.chars() + .filter(|c| !c.is_control()) + .map(|c| match c { + '\\' => "\\\\".to_string(), + '"' => "\\\"".to_string(), + c => c.to_string(), + }) + .collect() +} + +use hmac::{Hmac, Mac}; +use sha2::Sha256; + +type HmacSha256 = Hmac; + +fn hmac_cookie(cxid: &str, secret: &[u8]) -> Vec { + let mut mac = HmacSha256::new_from_slice(secret).expect("HMAC can take key of any size"); + mac.update(cxid.as_bytes()); + mac.finalize().into_bytes().to_vec() +} + +fn make_cookie_value(cxid: &str, secret: &[u8]) -> String { + use base64::Engine; + let mac = hmac_cookie(cxid, secret); + let mut raw = Vec::with_capacity(cxid.len() + 1 + mac.len()); + raw.extend_from_slice(cxid.as_bytes()); + raw.push(b':'); + raw.extend_from_slice(&mac); + base64::engine::general_purpose::STANDARD.encode(&raw) +} + +fn verify_cookie(cxid: &str, cookie_value: &str, secret: &[u8]) -> bool { + use base64::Engine; + let decoded = match base64::engine::general_purpose::STANDARD.decode(cookie_value) { + Ok(d) => d, + Err(_) => return false, + }; + let mut parts = decoded.splitn(2, |&b| b == b':'); + let decoded_cxid = match parts.next() { + Some(p) => match std::str::from_utf8(p) { + Ok(s) => s, + Err(_) => return false, + }, + None => return false, + }; + let mac_bytes = match parts.next() { + Some(p) => p, + None => return false, + }; + if decoded_cxid != cxid { + return false; + } + let expected = hmac_cookie(cxid, secret); + if mac_bytes.len() != expected.len() { + return false; + } + use subtle::ConstantTimeEq; + mac_bytes.ct_eq(&expected).into() +} diff --git a/crates/cgcx-storage/Cargo.toml b/crates/cgcx-storage/Cargo.toml new file mode 100644 index 0000000..a8f1ebe --- /dev/null +++ b/crates/cgcx-storage/Cargo.toml @@ -0,0 +1,11 @@ +[package] +name = "cgcx-storage" +version.workspace = true +edition.workspace = true + +[dependencies] +cgcx-core = { path = "../cgcx-core" } +cgcx-config = { path = "../cgcx-config" } +tokio = { version = "1", features = ["fs", "io-util"] } +tracing = "0.1" +tempfile = "3" diff --git a/crates/cgcx-storage/src/lib.rs b/crates/cgcx-storage/src/lib.rs new file mode 100644 index 0000000..e5adab2 --- /dev/null +++ b/crates/cgcx-storage/src/lib.rs @@ -0,0 +1,86 @@ +use cgcx_config::StoragePaths; +use cgcx_core::{ContentId, Result, CgcxError}; +use std::path::{Path, PathBuf}; +use tokio::fs; + +#[derive(Clone)] +pub struct Storage { + paths: StoragePaths, +} + +impl Storage { + pub fn new(paths: StoragePaths) -> Self { + Self { paths } + } + + pub async fn ensure_dirs(&self) -> Result<()> { + for dir in [&self.paths.media, &self.paths.documents, &self.paths.text, &self.paths.temp] { + fs::create_dir_all(dir).await.map_err(|e| CgcxError::Storage(format!("create dir {:?}: {}", dir, e)))?; + } + Ok(()) + } + + pub fn media_dir(&self) -> &Path { + &self.paths.media + } + + pub fn documents_dir(&self) -> &Path { + &self.paths.documents + } + + pub fn text_dir(&self) -> &Path { + &self.paths.text + } + + pub fn temp_dir(&self) -> &Path { + &self.paths.temp + } + + pub fn content_dir(&self, content_id: &ContentId, mime_type: &str) -> PathBuf { + let base = if mime_type.starts_with("image/") || mime_type.starts_with("video/") || mime_type.starts_with("audio/") { + &self.paths.media + } else if mime_type.starts_with("text/") { + &self.paths.text + } else { + &self.paths.documents + }; + base.join(content_id.as_str()) + } + + pub fn file_path(&self, content_id: &ContentId, file_index: u32, mime_type: &str) -> Result { + let base = if mime_type.starts_with("image/") || mime_type.starts_with("video/") || mime_type.starts_with("audio/") { + &self.paths.media + } else if mime_type.starts_with("text/") { + &self.paths.text + } else { + &self.paths.documents + }; + let dir = base.join(content_id.as_str()); + let file_name = format!("{}_{:04}.enc", content_id.as_str(), file_index); + let path = dir.join(file_name); + + if !path.starts_with(base) { + return Err(CgcxError::Storage("path traversal detected".into())); + } + + Ok(path) + } + + pub fn temp_file(&self) -> Result { + tempfile::NamedTempFile::new_in(&self.paths.temp) + .map_err(|e| CgcxError::Storage(format!("create temp file: {}", e))) + } + + pub async fn delete_content_files(&self, content_id: &ContentId, mime_type: &str) -> Result<()> { + let dir = self.content_dir(content_id, mime_type); + if dir.exists() { + fs::remove_dir_all(&dir).await.map_err(|e| CgcxError::Storage(format!("remove dir {:?}: {}", dir, e)))?; + } + Ok(()) + } + + pub async fn file_size(&self, path: &Path) -> Result { + let meta = fs::metadata(path).await.map_err(|e| CgcxError::Storage(format!("metadata {:?}: {}", path, e)))?; + Ok(meta.len()) + } +} diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..ff9ca7f --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,129 @@ +# Architecture & Design Decisions + +This document explains the deeper design choices behind cg.cx — the trade-offs, threat models, and engineering rationale that shaped the system. + +--- + +## Why XChaCha20-Poly1305 over AES-GCM? + +We chose **XChaCha20-Poly1305** (via libsodium's `crypto_secretstream_xchacha20poly1305`) as the bulk encryption primitive for several reasons: + +1. **Nonce-misuse resistance**: AES-GCM's security collapses catastrophically if a nonce is ever reused. XChaCha20 uses a 192-bit nonce, making accidental collisions statistically impossible even with billions of files. This removes an entire class of operator error. +2. **No hardware dependency**: AES-GCM performance relies heavily on AES-NI. XChaCha20 performs well on all platforms — including older or virtualized CPUs where AES-NI may be unavailable or disabled. +3. **Streaming integrity**: libsodium's `secretstream` API provides built-in chunked authenticated encryption with `Message` and `Final` tags. This gives us streaming decryption with per-chunk integrity checks without inventing our own framing protocol. +4. **Simpler key management**: Because nonce collisions are not a practical concern, we can generate a fresh random key for every file without tracking nonce counters or key lifecycles. + +AES is still present in the system — we use **AES-256-KW** (Key Wrap) to encrypt the per-file content keys (CEKs) with the master key. AES-KW was chosen because it is a standard, deterministic, and widely audited key-wrapping algorithm with built-in integrity. + +--- + +## Why SQLite over PostgreSQL? + +For a self-hosted, single-tenant service handling encrypted file metadata, **SQLite** is the correct default: + +1. **Operational simplicity**: No separate database server to install, upgrade, or network-secure. A single `.sqlite` file is trivial to back up, replicate, or inspect. +2. **WAL mode performance**: With `PRAGMA journal_mode = WAL`, SQLite handles concurrent readers and a single writer efficiently — enough for a bot + web server pair. +3. **Schema simplicity**: The schema is small (5 tables, 2 migration files). The overhead of a client/server RDBMS is unjustified. +4. **Deployment footprint**: Ideal for running on a small VPS or even an embedded edge device without container orchestration. + +If future requirements demand horizontal scaling or heavy analytics, the repository pattern in `cgcx-db` makes it straightforward to swap in PostgreSQL without touching the bot or server code. + +--- + +## Why a Modular 10-Crate Workspace? + +The crate graph was designed to enforce architectural boundaries at compile time: + +``` +cgcx-core + ▲ + ├── cgcx-config + ├── cgcx-crypto + ├── cgcx-db + ├── cgcx-storage + ├── cgcx-content-typing + │ ▲ + │ └── cgcx-file-pipeline + ├── cgcx-moderation + │ + └── binaries: cgcx-bot, cgcx-server +``` + +- **cgcx-core** sits at the root and contains only pure data types. It has no I/O dependencies, making it safe to import anywhere. +- **cgcx-crypto** depends only on `cgcx-core`. It is side-effect-free and easy to property-test. +- **cgcx-db** and **cgcx-storage** are I/O crates but know nothing about Telegram or HTTP. +- **cgcx-file-pipeline** composes crypto, storage, typing, and DB into the upload workflow. +- The **binaries** are thin shells that wire configuration to the library crates. + +This structure makes it impossible for a database query to accidentally invoke Telegram API code, or for HTTP handlers to directly touch the filesystem without going through the storage abstraction. + +--- + +## Streaming Design for Large Files + +Uploads from Telegram are bounded by Telegram's own file size limits (currently 2 GB for bots), but we still treat streaming as a first-class concern: + +### Upload Path + +1. The bot downloads the file into a `Vec` in memory. +2. The file pipeline encrypts the data in 1 MiB chunks, writing ciphertext directly to a temp file on disk. +3. After the final chunk is written and flushed, the temp file is atomically renamed to its final destination. +4. Only metadata (original name, MIME type, wrapped key, BLAKE3 hash) hits the database. + +This ensures that even a 1 GB upload does not require a 1 GB contiguous memory allocation for ciphertext. + +### Download Path + +1. The Axum handler spawns a Tokio task that opens the encrypted file. +2. It reads the 24-byte secretstream header, unwraps the CEK, and initializes a `DecryptStream`. +3. A bounded MPSC channel (`capacity = 4`) decouples disk I/O from the HTTP response stream. +4. Ciphertext is read from disk in ~1 MiB chunks, decrypted, and sent through the channel. +5. Axum's `Body::from_stream` forwards plaintext chunks to the client as they are produced. + +If the client disconnects mid-stream, the sender half of the channel is dropped and the decryption task exits cleanly. No full-file buffering occurs on the server. + +--- + +## Security Threat Model + +### What We Protect Against + +| Threat | Mitigation | +|--------|------------| +| **Server compromise (passive)** | All files are encrypted at rest with per-file keys. An attacker with disk access cannot read plaintext without the master key. | +| **Database leak** | The database contains only wrapped keys, ciphertext hashes, and metadata. It does not contain plaintext or unwrapped CEKs. | +| **Ciphertext tampering** | XChaCha20-Poly1305 authenticates every chunk. Tampered files fail decryption and the stream aborts. | +| **Brute-force password guessing** | Per-content passwords are hashed with bcrypt. Rate limiting on `/api/content/:cxid/verify-password` slows online attacks. | +| **Cookie forgery** | Password session cookies include a BLAKE3 MAC keyed by the master key. Forging a cookie requires knowledge of the master key. | +| **Replay / enumeration** | Content IDs are 12-character random strings with ~71 bits of entropy. They are not sequential. | +| **Malicious uploads** | Content typing flags executable, HTML, and script MIME types. The frontend refuses to inline dangerous files. | + +### What We Do Not Protect Against + +| Threat | Rationale | +|--------|-----------| +| **Active server compromise (key extraction)** | If an attacker gains code execution and reads the master key from memory or env, they can decrypt all content. This is an inherent limitation of server-side encryption. | +| **Telegram MitM** | We trust Telegram's bot API transport (HTTPS) and file CDN. | +| **Client-side malware** | The user's browser or device may be compromised; we cannot protect plaintext after decryption. | +| **Denial of Service** | Large uploads and high request volumes can exhaust disk or bandwidth. Rate limiting and upload size caps mitigate but do not eliminate this risk. | + +### Trust Boundaries + +``` +[User Device] --HTTPS--> [Telegram Cloud] --HTTPS--> [cg.cx Bot] + | +[Browser] <--HTTPS--> [cg.cx Server] <--------┘ + | + Decrypted plaintext rendered in browser +``` + +The **cg.cx server** is a trusted party for decryption and delivery. It is not a true "end-to-end" system in the Signal sense, because the server must unwrap keys to stream content to browsers that do not possess the master key. The architecture prioritizes **usable sharing** (anyone with a link can view) over **true E2EE** (which would require client-side JavaScript crypto and key distribution). + +--- + +## Future Considerations + +- **Client-side decryption**: A future iteration could deliver the wrapped CEK to the browser and decrypt via WebAssembly / libsodium-js. This would remove the server from the trust boundary for delivery. +- **S3-compatible backends**: `cgcx-storage` could be abstracted into a trait to support object storage. +- **PostgreSQL backend**: The repository trait pattern in `cgcx-db` is amenable to an async SQLx implementation. +- **Metrics and alerting**: Structured tracing is in place; a metrics exporter (Prometheus) could be added to `cgcx-server` without touching business logic. diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..dd51b14 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + cg.cx + + +
+
+ + + diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..1f86cf1 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,19 @@ +{ + "name": "cgcx-frontend", + "version": "0.1.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "vite build", + "preview": "vite preview" + }, + "devDependencies": { + "@sveltejs/vite-plugin-svelte": "^4.0.0", + "svelte": "^5.0.0", + "vite": "^5.0.0" + }, + "dependencies": { + "marked": "^12.0.0", + "dompurify": "^3.0.0" + } +} diff --git a/frontend/public/fonts/press-start-2p-latin.woff2 b/frontend/public/fonts/press-start-2p-latin.woff2 new file mode 100644 index 0000000..432a9e3 Binary files /dev/null and b/frontend/public/fonts/press-start-2p-latin.woff2 differ diff --git a/frontend/public/fonts/vt323-latin.woff2 b/frontend/public/fonts/vt323-latin.woff2 new file mode 100644 index 0000000..eb55d5c Binary files /dev/null and b/frontend/public/fonts/vt323-latin.woff2 differ diff --git a/frontend/src/App.svelte b/frontend/src/App.svelte new file mode 100644 index 0000000..7f77425 --- /dev/null +++ b/frontend/src/App.svelte @@ -0,0 +1,29 @@ + + + + +{#if cxid} + +{:else} + +{/if} diff --git a/frontend/src/components/AudioPlayer.svelte b/frontend/src/components/AudioPlayer.svelte new file mode 100644 index 0000000..f3638ae --- /dev/null +++ b/frontend/src/components/AudioPlayer.svelte @@ -0,0 +1,31 @@ + + +
+
[ Audio ]
+ +
+ + diff --git a/frontend/src/components/DocumentCard.svelte b/frontend/src/components/DocumentCard.svelte new file mode 100644 index 0000000..a4112b9 --- /dev/null +++ b/frontend/src/components/DocumentCard.svelte @@ -0,0 +1,59 @@ + + +
+
[DOC]
+
+

{file.name}

+

{file.mime} • {formatSize(file.size)}

+
+ Download +
+ + diff --git a/frontend/src/components/ExecutableWarning.svelte b/frontend/src/components/ExecutableWarning.svelte new file mode 100644 index 0000000..c113581 --- /dev/null +++ b/frontend/src/components/ExecutableWarning.svelte @@ -0,0 +1,56 @@ + + +
+
[!] DANGEROUS FILE
+

{file.name}

+

{file.mime} • {formatSize(file.size)}

+

+ This file may be dangerous. Never execute unknown files. Only download if you trust the source. +

+ Download at your own risk +
+ + diff --git a/frontend/src/components/ImageViewer.svelte b/frontend/src/components/ImageViewer.svelte new file mode 100644 index 0000000..51e2a0d --- /dev/null +++ b/frontend/src/components/ImageViewer.svelte @@ -0,0 +1,22 @@ + + +
+ {name} +
+ + diff --git a/frontend/src/components/LoadingScreen.svelte b/frontend/src/components/LoadingScreen.svelte new file mode 100644 index 0000000..b7c4226 --- /dev/null +++ b/frontend/src/components/LoadingScreen.svelte @@ -0,0 +1,160 @@ + + +{#if visible} +
+
+
+
+ +
+
+
+
+
+{/if} + + diff --git a/frontend/src/components/MarkdownRenderer.svelte b/frontend/src/components/MarkdownRenderer.svelte new file mode 100644 index 0000000..84739c2 --- /dev/null +++ b/frontend/src/components/MarkdownRenderer.svelte @@ -0,0 +1,79 @@ + + +
+ {#if loading} +

Loading markdown...

+ {:else} + {@html html} + {/if} +
+ + diff --git a/frontend/src/components/MixedGallery.svelte b/frontend/src/components/MixedGallery.svelte new file mode 100644 index 0000000..671c463 --- /dev/null +++ b/frontend/src/components/MixedGallery.svelte @@ -0,0 +1,82 @@ + + + + + diff --git a/frontend/src/components/TextViewer.svelte b/frontend/src/components/TextViewer.svelte new file mode 100644 index 0000000..891632e --- /dev/null +++ b/frontend/src/components/TextViewer.svelte @@ -0,0 +1,46 @@ + + +
+ {#if loading} +

Loading text...

+ {:else} +
{text}
+ {/if} +
+ + diff --git a/frontend/src/components/VideoPlayer.svelte b/frontend/src/components/VideoPlayer.svelte new file mode 100644 index 0000000..812d5c8 --- /dev/null +++ b/frontend/src/components/VideoPlayer.svelte @@ -0,0 +1,23 @@ + + +
+ + +
+ + diff --git a/frontend/src/lib/api.js b/frontend/src/lib/api.js new file mode 100644 index 0000000..8def8fb --- /dev/null +++ b/frontend/src/lib/api.js @@ -0,0 +1,38 @@ +const API_BASE = "http://127.0.0.1:8090"; + +export async function fetchMetadata(cxid) { + const res = await fetch( + `${API_BASE}/api/content/${encodeURIComponent(cxid)}`, + ); + if (!res.ok) throw new Error(await res.text()); + return res.json(); +} + +export async function verifyPassword(cxid, password) { + const res = await fetch( + `${API_BASE}/api/content/${encodeURIComponent(cxid)}/verify-password`, + { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ password }), + credentials: "same-origin", + }, + ); + return res.ok; +} + +export function fileUrl(cxid, fileIdx, download = false, password = "") { + let url = `${API_BASE}/api/content/${encodeURIComponent(cxid)}/file/${fileIdx}`; + if (download) url += "?download=1"; + if (password) + url += (download ? "&" : "?") + `sc=${encodeURIComponent(password)}`; + return url; +} + +export function formatSize(bytes) { + if (bytes === 0) return "0 B"; + const units = ["B", "KB", "MB", "GB"]; + const i = Math.min(Math.floor(Math.log2(bytes) / 10), units.length - 1); + const value = bytes / Math.pow(1024, i); + return `${value.toFixed(i > 0 ? 1 : 0)} ${units[i]}`; +} diff --git a/frontend/src/main.js b/frontend/src/main.js new file mode 100644 index 0000000..282f2bb --- /dev/null +++ b/frontend/src/main.js @@ -0,0 +1,12 @@ +import { mount } from 'svelte' +import App from './App.svelte' +import './styles/global.css' + +const loadingScreen = document.getElementById('loading-screen') +if (loadingScreen) loadingScreen.remove() + +const app = mount(App, { + target: document.getElementById('app'), +}) + +export default app diff --git a/frontend/src/routes/Home.svelte b/frontend/src/routes/Home.svelte new file mode 100644 index 0000000..094cce4 --- /dev/null +++ b/frontend/src/routes/Home.svelte @@ -0,0 +1,128 @@ + + +
+
+

CG.CX

+

Secure content sharing

+
+ +
+ + + + {#if needsPassword} + + + {/if} + + {#if error} +

{error}

+ {/if} + + +
+ + +
+ + diff --git a/frontend/src/routes/ViewContent.svelte b/frontend/src/routes/ViewContent.svelte new file mode 100644 index 0000000..9da2e78 --- /dev/null +++ b/frontend/src/routes/ViewContent.svelte @@ -0,0 +1,183 @@ + + +
+ {#if phase === 'loading_meta'} +
+

Loading...

+
+ {:else if phase === 'password_required'} +
+
+

[ Protected ]

+

This content requires a password.

+ e.key === 'Enter' && submitPassword()} /> + {#if error}

{error}

{/if} + + +
+
+ {:else if phase === 'error'} +
+

{error}

+ +
+ {:else if phase === 'rendering'} +
+ + {metadata.files.length} file{metadata.files.length !== 1 ? 's' : ''} + {#if metadata.max_views} + Views: {metadata.current_views}/{metadata.max_views} + {/if} +
+ + {#if metadata.files.length === 1} + {@const file = metadata.files[0]} + {@const viewer = getViewerFor(file)} + {#if viewer === 'image'} + + {:else if viewer === 'video'} + + {:else if viewer === 'audio'} + + {:else if viewer === 'markdown'} + + {:else if viewer === 'text'} + + {:else if viewer === 'executable' || viewer === 'dangerous'} + + {:else} + + {/if} + {:else} + + {/if} + {/if} +
+ + diff --git a/frontend/src/styles/global.css b/frontend/src/styles/global.css new file mode 100644 index 0000000..b7e057d --- /dev/null +++ b/frontend/src/styles/global.css @@ -0,0 +1,124 @@ +@import './retro-theme.css'; + +@font-face { + font-family: 'Press Start 2P'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/press-start-2p-latin.woff2') format('woff2'); +} + +@font-face { + font-family: 'VT323'; + font-style: normal; + font-weight: 400; + font-display: swap; + src: url('/fonts/vt323-latin.woff2') format('woff2'); +} + +html { + font-size: 18px; +} + +body { + font-family: 'VT323', monospace; + background: var(--retro-bg); + color: var(--retro-fg); + line-height: 1.4; + -webkit-font-smoothing: antialiased; + -moz-osx-font-smoothing: grayscale; +} + +a { + color: var(--retro-green); + text-decoration: none; +} +a:hover { + text-decoration: underline; +} + +button, .btn { + font-family: 'Press Start 2P', cursive; + font-size: 0.65rem; + padding: 12px 16px; + border: 3px solid var(--retro-green); + background: var(--retro-panel); + color: var(--retro-green); + cursor: pointer; + text-transform: uppercase; + box-shadow: 3px 3px 0px rgba(0,0,0,0.15); +} + +button:disabled, .btn:disabled { + opacity: 0.5; + cursor: not-allowed; +} + +input[type="text"], +input[type="password"] { + font-family: 'VT323', monospace; + font-size: 1.1rem; + padding: 10px 12px; + border: 2px solid var(--retro-border); + background: #fff; + color: var(--retro-fg); + outline: none; + width: 100%; + box-sizing: border-box; +} +input:focus { + border-color: var(--retro-green); +} + +.retro-heading { + font-family: 'Press Start 2P', cursive; + font-size: clamp(0.9rem, 3vw, 1.4rem); + line-height: 1.6; + color: var(--retro-green); + background: linear-gradient(135deg, var(--retro-green), var(--retro-green-light), var(--retro-green)); + background-size: 200% 200%; + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; +} + +@keyframes gradient-shift { + 0% { background-position: 0% 50%; } + 50% { background-position: 100% 50%; } + 100% { background-position: 0% 50%; } +} + +@media (prefers-reduced-motion: no-preference) { + button, .btn { + transition: transform 0.1s ease, box-shadow 0.1s ease; + } + button:hover, .btn:hover { + transform: translate(-1px, -1px); + box-shadow: 4px 4px 0px rgba(0,0,0,0.2); + } + button:active, .btn:active { + transform: translate(2px, 2px); + box-shadow: 1px 1px 0px rgba(0,0,0,0.15); + } + .retro-heading { + animation: gradient-shift 3s ease infinite; + } +} + +@media (max-width: 768px) { + html { font-size: 17px; } +} + +@media (max-width: 480px) { + html { font-size: 16px; } + button, .btn { font-size: 0.55rem; padding: 10px 12px; } +} + +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: 0.01ms !important; + animation-iteration-count: 1 !important; + transition-duration: 0.01ms !important; + scroll-behavior: auto !important; + } +} diff --git a/frontend/src/styles/retro-theme.css b/frontend/src/styles/retro-theme.css new file mode 100644 index 0000000..3325e43 --- /dev/null +++ b/frontend/src/styles/retro-theme.css @@ -0,0 +1,12 @@ +:root { + --retro-bg: #fafafa; + --retro-fg: #111111; + --retro-green: #1a4a1a; + --retro-green-light: #2e8b2e; + --retro-accent: #0f380f; + --retro-border: #cccccc; + --retro-panel: #ffffff; + --retro-shadow: rgba(0, 0, 0, 0.12); + --retro-danger: #8b1a1a; + --retro-warning: #8b5a1a; +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..9b0e9f6 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,10 @@ +import { defineConfig } from 'vite' +import { svelte } from '@sveltejs/vite-plugin-svelte' + +export default defineConfig({ + plugins: [svelte()], + build: { + outDir: 'dist', + emptyOutDir: true, + }, +}) diff --git a/migrations/001_init.sql b/migrations/001_init.sql new file mode 100644 index 0000000..032d2e5 --- /dev/null +++ b/migrations/001_init.sql @@ -0,0 +1,58 @@ +CREATE TABLE users ( + id INTEGER PRIMARY KEY, + telegram_username TEXT, + first_name TEXT NOT NULL, + role TEXT NOT NULL DEFAULT 'user' + CHECK (role IN ('user', 'admin', 'banned')), + accepted_terms_at DATETIME, + created_at DATETIME NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE contents ( + id TEXT PRIMARY KEY, + user_id INTEGER NOT NULL REFERENCES users(id), + status TEXT NOT NULL DEFAULT 'active' + CHECK (status IN ('staged', 'active', 'deleted', 'blacklisted')), + view_count INTEGER NOT NULL DEFAULT 0, + max_views INTEGER, + allow_download INTEGER NOT NULL DEFAULT 1, + password_hash TEXT, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + deleted_at DATETIME +); + +CREATE TABLE content_files ( + content_id TEXT NOT NULL REFERENCES contents(id), + file_index INTEGER NOT NULL DEFAULT 0, + original_name TEXT NOT NULL, + stored_path TEXT NOT NULL UNIQUE, + mime_type TEXT NOT NULL, + size_bytes INTEGER NOT NULL, + ciphertext_size_bytes INTEGER NOT NULL, + encrypted_key_wrapped BLOB NOT NULL, + encrypted_hash BLOB NOT NULL, + render_flags INTEGER NOT NULL DEFAULT 0, + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + PRIMARY KEY (content_id, file_index) +); + +CREATE TABLE reports ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + content_id TEXT NOT NULL REFERENCES contents(id), + reporter_user_id INTEGER NOT NULL REFERENCES users(id), + reason TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'open' + CHECK (status IN ('open', 'dismissed', 'actioned')), + created_at DATETIME NOT NULL DEFAULT (datetime('now')), + resolved_at DATETIME, + resolver_id INTEGER REFERENCES users(id) +); + +CREATE TABLE admin_actions ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + admin_user_id INTEGER NOT NULL REFERENCES users(id), + target_type TEXT NOT NULL CHECK (target_type IN ('content', 'user')), + target_id TEXT NOT NULL, + action TEXT NOT NULL CHECK (action IN ('delete', 'blacklist', 'ignore')), + created_at DATETIME NOT NULL DEFAULT (datetime('now')) +); diff --git a/migrations/002_indexes.sql b/migrations/002_indexes.sql new file mode 100644 index 0000000..ab2ff49 --- /dev/null +++ b/migrations/002_indexes.sql @@ -0,0 +1,7 @@ +CREATE INDEX idx_contents_user_id ON contents(user_id); +CREATE INDEX idx_contents_status_created ON contents(status, created_at); +CREATE INDEX idx_contents_deleted_at ON contents(deleted_at) WHERE deleted_at IS NOT NULL; +CREATE INDEX idx_content_files_content_id ON content_files(content_id); +CREATE INDEX idx_content_files_hash ON content_files(encrypted_hash); +CREATE INDEX idx_reports_content_id ON reports(content_id); +CREATE INDEX idx_reports_status ON reports(status);