From a631d75cee0fe749c93eef9049add8de41fd7077 Mon Sep 17 00:00:00 2001 From: TheBadRoger Date: Thu, 15 Oct 2026 20:08:15 +0800 Subject: [PATCH] Initialize ChuBlog backend with SSH Git publishing --- .env.example | 11 + .gitignore | 11 + Cargo.lock | 1442 ++++++++++++++++++++ Cargo.toml | 3 + PLAN.md | 225 +++ README.md | 126 ++ TODO.md | 64 + apps/api/Cargo.toml | 26 + apps/api/src/accounts.rs | 657 +++++++++ apps/api/src/content.rs | 374 +++++ apps/api/src/index.rs | 145 ++ apps/api/src/main.rs | 330 +++++ apps/api/src/ssh.rs | 274 ++++ apps/api/src/tests.rs | 614 +++++++++ content/posts/welcome/index.md | 19 + deploy/.dockerignore | 16 + deploy/Caddyfile | 25 + deploy/Dockerfile | 38 + deploy/authorized-keys.sh | 3 + deploy/chusql.toml | 9 + deploy/compose.yaml | 137 ++ deploy/db-entrypoint.sh | 7 + deploy/gitconfig | 2 + deploy/hooks/pre-receive | 3 + deploy/init-volumes.sh | 14 + deploy/ssh-entrypoint.sh | 7 + deploy/sshd_config | 33 + docs/API.md | 30 + docs/decisions/0001-git-only-publishing.md | 21 + scripts/prepare-deploy.py | 24 + scripts/smoke.py | 160 +++ 31 files changed, 4850 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 Cargo.lock create mode 100644 Cargo.toml create mode 100755 PLAN.md create mode 100644 README.md create mode 100644 TODO.md create mode 100644 apps/api/Cargo.toml create mode 100644 apps/api/src/accounts.rs create mode 100644 apps/api/src/content.rs create mode 100644 apps/api/src/index.rs create mode 100644 apps/api/src/main.rs create mode 100644 apps/api/src/ssh.rs create mode 100644 apps/api/src/tests.rs create mode 100644 content/posts/welcome/index.md create mode 100644 deploy/.dockerignore create mode 100644 deploy/Caddyfile create mode 100644 deploy/Dockerfile create mode 100644 deploy/authorized-keys.sh create mode 100644 deploy/chusql.toml create mode 100644 deploy/compose.yaml create mode 100644 deploy/db-entrypoint.sh create mode 100644 deploy/gitconfig create mode 100644 deploy/hooks/pre-receive create mode 100644 deploy/init-volumes.sh create mode 100644 deploy/ssh-entrypoint.sh create mode 100644 deploy/sshd_config create mode 100644 docs/API.md create mode 100644 docs/decisions/0001-git-only-publishing.md create mode 100755 scripts/prepare-deploy.py create mode 100755 scripts/smoke.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..cddcb6a --- /dev/null +++ b/.env.example @@ -0,0 +1,11 @@ +PUBLIC_ORIGIN=https://blog.example.com +SITE_ADDRESS=blog.example.com +ADMIN_USERNAME=admin +# Generate with chublog-api hash-password; keep single quotes around the hash. +ADMIN_PASSWORD_HASH='$argon2id$REPLACE_WITH_GENERATED_HASH' +ALLOW_REGISTRATION=true +CHUSQL_ROOT_PASSWORD=replace-with-random-root-password-at-least-16-characters +CHUSQL_PASSWORD=replace-with-different-random-index-password-at-least-16-characters +SSH_PORT=2222 +HTTP_PORT=80 +HTTPS_PORT=443 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..120402a --- /dev/null +++ b/.gitignore @@ -0,0 +1,11 @@ +/target/ +/data/ +.env +node_modules/ +dist/ +.astro/ +*.log +/deploy/bin/ +/deploy/secrets/ +__pycache__/ +*.pyc diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..335c8a0 --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,1442 @@ +# 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 = "aho-corasick" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c982642fa9e8606056828ee9a8505737230110bb1099153c79efe865c59d12ba" +dependencies = [ + "memchr", +] + +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "argon2" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" +dependencies = [ + "base64ct", + "blake2", + "cpufeatures", + "password-hash", +] + +[[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.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "axum" +version = "0.8.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" +dependencies = [ + "axum-core", + "bytes", + "form_urlencoded", + "futures-util", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-util", + "itoa", + "matchit", + "memchr", + "mime", + "percent-encoding", + "pin-project-lite", + "serde_core", + "serde_json", + "serde_path_to_error", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tower", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "axum-core" +version = "0.5.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08c78f31d7b1291f7ee735c1c6780ccde7785daae9a9206026862dab7d8792d1" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "http-body-util", + "mime", + "pin-project-lite", + "sync_wrapper", + "tower-layer", + "tower-service", + "tracing", +] + +[[package]] +name = "base64" +version = "0.22.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" + +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bitflags" +version = "2.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3ded4057c258ba199e2d26386d3af3780957ecaee6c4ef4041c6b4b8b97c0b06" + +[[package]] +name = "blake2" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "46502ad458c9a52b69d4d4d32775c788b7a1b85e8bc9d482d92250fc0e3f8efe" +dependencies = [ + "digest", +] + +[[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.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + +[[package]] +name = "bytemuck" +version = "1.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "95832e849adfb21180ccb6826a99da14e5d266ae5c2e668e1602cf234f153797" + +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + +[[package]] +name = "bytes" +version = "1.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc652a48c352aef3ea3aed32080501cf3ef6ed5da78602a020c991775b0aff04" + +[[package]] +name = "cc" +version = "1.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6651c9ed80effdc7db0ff72512157f901af5e3549e341e24b1dd4887d836d838" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" + +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "js-sys", + "num-traits", + "serde", + "wasm-bindgen", + "windows-link", +] + +[[package]] +name = "chublog-api" +version = "0.1.0" +dependencies = [ + "anyhow", + "argon2", + "axum", + "base64", + "chrono", + "chusql-driver", + "fs2", + "image", + "pulldown-cmark", + "rand", + "serde", + "serde_json", + "serde_yaml", + "tempfile", + "tokio", + "tower", + "tracing", + "tracing-subscriber", +] + +[[package]] +name = "chusql-driver" +version = "0.1.0" +dependencies = [ + "serde", + "serde_json", + "tokio", +] + +[[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 = "crc32fast" +version = "1.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "01a7799fd6b852db0e61728dde9a204c423b44d689dbd432522543614b490e78" +dependencies = [ + "cfg-if", +] + +[[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 = "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 = "equivalent" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00d174d5400e5e8fd687ad1049e2f578285fa914201b1af7e8b112a4546bd826" + +[[package]] +name = "errno" +version = "0.3.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "fastrand" +version = "2.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" + +[[package]] +name = "fdeflate" +version = "0.3.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e6853b52649d4ac5c0bd02320cddc5ba956bdb407c4b75a2c6b75bf51500f8c" +dependencies = [ + "simd-adler32", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aedcfb3409746eddb02b9e19ebda1c3394f759a152e48ee875a0844d1b955484" + +[[package]] +name = "flate2" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6e634e2e0ebac1ee034020da1ca582e17ffe4e0f5e985823721e168928136dcb" +dependencies = [ + "crc32fast", + "miniz_oxide 0.9.1", + "zlib-rs", +] + +[[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 = "fs2" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9564fc758e15025b46aa6643b1b77d047d1a56a1aea6e01002ac0c7026876213" +dependencies = [ + "libc", + "winapi", +] + +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "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 = "getopts" +version = "0.2.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe4fbac503b8d1f88e6676011885f34b7174f46e59956bba534ba83abded4df" +dependencies = [ + "unicode-width", +] + +[[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.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi", +] + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "http" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "918d3568bebf352712bc2ef3d46a8bcf1a75b373be6539de198e9105cbbf9ce0" +dependencies = [ + "bytes", + "itoa", +] + +[[package]] +name = "http-body" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ca2a8f2913ee65f60facd6a5905613afaa448497a0230cc41ce022d93290bc2c" +dependencies = [ + "bytes", + "http", +] + +[[package]] +name = "http-body-util" +version = "0.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23169fe34a5fbcdd3f3862e78fb9b6fccd5f02a6dc6f732547005d45631ce71c" +dependencies = [ + "bytes", + "futures-core", + "http", + "http-body", + "pin-project-lite", +] + +[[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 = "1.12.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c3e324da4c95177d6291d4c8730197c0d1822f8a9766814a4a44fa5ab797c9c" +dependencies = [ + "atomic-waker", + "bytes", + "futures-core", + "http", + "http-body", + "httparse", + "httpdate", + "itoa", + "pin-project-lite", + "smallvec", + "tokio", +] + +[[package]] +name = "hyper-util" +version = "0.1.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddc03d96684f9226b8a787cdb71488417b53ab5ea8fdb1dac946cb9431cc8bff" +dependencies = [ + "bytes", + "http", + "http-body", + "hyper", + "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 = "image" +version = "0.25.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85ab80394333c02fe689eaf900ab500fbd0c2213da414687ebf995a65d5a6104" +dependencies = [ + "bytemuck", + "byteorder-lite", + "image-webp", + "moxcms", + "num-traits", + "png", + "zune-core", + "zune-jpeg", +] + +[[package]] +name = "image-webp" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" +dependencies = [ + "byteorder-lite", + "quick-error", +] + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[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.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + +[[package]] +name = "lazy_static" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "20870f649af7073d53e38067b2a84312175d56ea15217e1b15bc83506ec50afb" + +[[package]] +name = "libc" +version = "0.2.190" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce5d3ddc6d3fa000eb1536d85e147bfe31aacaba692ed6a876f95cb7c855be78" + +[[package]] +name = "linux-raw-sys" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32a66949e030da00e8c7d4434b251670a91556f4144941d37452769c25d58a53" + +[[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.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f9f8bd3e56ce4dfc153cf470fffbfa98c7620958b312ca5c3a4b8d5181fd13c6" + +[[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.8.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47e1ffaa40ddd1f3ed91f717a33c8c0ee23fff369e3aa8772b9605cc1d22f4c3" + +[[package]] +name = "memchr" +version = "2.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf8baf1c55e62ffcace7a9f06f4bd9cd3f0c4beb022d3b367256b91b87513d98" + +[[package]] +name = "mime" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" + +[[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 = "miniz_oxide" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b63fbc4a50860e98e7b2aa7804ded1db5cbc3aff9193adaff57a6931bf7c4b4c" +dependencies = [ + "adler2", + "simd-adler32", +] + +[[package]] +name = "mio" +version = "1.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" +dependencies = [ + "libc", + "wasi", + "windows-sys", +] + +[[package]] +name = "moxcms" +version = "0.8.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bb85c154ba489f01b25c0d36ae69a87e4a1c73a72631fc6c0eb6dde34a73e44b" +dependencies = [ + "num-traits", + "pxfm", +] + +[[package]] +name = "nu-ansi-term" +version = "0.50.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5" +dependencies = [ + "windows-sys", +] + +[[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 = "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", + "subtle", +] + +[[package]] +name = "percent-encoding" +version = "2.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" + +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + +[[package]] +name = "png" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "60769b8b31b2a9f263dae2776c37b1b28ae246943cf719eb6946a1db05128a61" +dependencies = [ + "bitflags", + "crc32fast", + "fdeflate", + "flate2", + "miniz_oxide 0.8.9", +] + +[[package]] +name = "ppv-lite86" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "85eae3c4ed2f50dcfe72643da4befc30deadb458a9b590d720cde2f2b1e97da9" +dependencies = [ + "zerocopy", +] + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "pulldown-cmark" +version = "0.13.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9f068eba8e7071c5f9511831b44f32c740d5adf574e990f946ddb53db2f314e" +dependencies = [ + "bitflags", + "getopts", + "memchr", + "pulldown-cmark-escape", + "unicase", +] + +[[package]] +name = "pulldown-cmark-escape" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "007d8adb5ddab6f8e3f491ac63566a7d5002cc7ed73901f72057943fa71ae1ae" + +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[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.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" +dependencies = [ + "libc", + "rand_chacha", + "rand_core", +] + +[[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", +] + +[[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 = "redox_syscall" +version = "0.5.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" +dependencies = [ + "bitflags", +] + +[[package]] +name = "regex-automata" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8553b9b26413251cbf30e620595c7a41b3887f03da04579c0e6b0d6a06b4b2" +dependencies = [ + "aho-corasick", + "memchr", + "regex-syntax", +] + +[[package]] +name = "regex-syntax" +version = "0.8.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d6f6ff9a378485b298a5286656da665ba74413d36db0979633275d2e708145d4" + +[[package]] +name = "rustix" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891efababe418670775f199f0d233d84843c227a0949a883ce15b37c78d6629d" +dependencies = [ + "bitflags", + "errno", + "libc", + "linux-raw-sys", + "windows-sys", +] + +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "scopeguard" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[package]] +name = "serde_json" +version = "1.0.154" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7e9cc8b1b85264074fbcc02a88680c4096b1e47df8f739dceb03bf482f04bd6" +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_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_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[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 = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[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 = "simd-adler32" +version = "0.3.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea" + +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + +[[package]] +name = "smallvec" +version = "1.16.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5b3dc8af474f516a851ff4bd12db780f948b9250ad37211e4eec0bccea54e01b" + +[[package]] +name = "socket2" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" +dependencies = [ + "libc", + "windows-sys", +] + +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "sync_wrapper" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0bf256ce5efdfa370213c1dabab5935a12e49f2c58d15e9eac2870d3b4f27263" + +[[package]] +name = "tempfile" +version = "3.27.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32497e9a4c7b38532efcdebeef879707aa9f794296a4f0244f6f69e9bc8574bd" +dependencies = [ + "fastrand", + "getrandom 0.4.3", + "once_cell", + "rustix", + "windows-sys", +] + +[[package]] +name = "thread_local" +version = "1.1.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1ad99c4c6d32803332c548b1af0540b357b3f5fc0be8f6c6bfe8b2e6ae784070" +dependencies = [ + "cfg-if", +] + +[[package]] +name = "tokio" +version = "1.53.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ce3335fa71841cda333a58d7615b03901380ecf09d59b3296d21f8bbac0dde4e" +dependencies = [ + "bytes", + "libc", + "mio", + "parking_lot", + "pin-project-lite", + "signal-hook-registry", + "socket2", + "tokio-macros", + "windows-sys", +] + +[[package]] +name = "tokio-macros" +version = "2.7.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.9", +] + +[[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", + "tokio", + "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 = "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.119", +] + +[[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 = "typenum" +version = "1.20.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20" + +[[package]] +name = "unicase" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "357cc3acc6a036009fd6c973ed009037c732d60d0b4f6c673e9041497482a28f" + +[[package]] +name = "unicode-ident" +version = "1.0.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2c754d6c33795a1c324727428e5a7dedb5b06195f9890bdbcba760d3e246563" + +[[package]] +name = "unicode-width" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "valuable" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" + +[[package]] +name = "version_check" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a" + +[[package]] +name = "wasi" +version = "0.11.1+wasi-snapshot-preview1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" + +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.9", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + +[[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-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.119", +] + +[[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.119", +] + +[[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.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "zerocopy" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e5fe1f8f1b06191a00962174c61aa5005e0bb391a6d80d07e24d115c01a92ed8" +dependencies = [ + "zerocopy-derive", +] + +[[package]] +name = "zerocopy-derive" +version = "0.8.63" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "863ad3ac83293fb4d740aedbfdc9240dd8d1a50c1099acd76ce80ce7c7230c7f" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "zlib-rs" +version = "0.6.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b268e58e7c693d7c271f93ffc4ba3b380412554231c85bf61ca7af91042a4112" + +[[package]] +name = "zmij" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.5.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d56377fd46368984a170bc5aac5567e52ca5da874caa60bea39fcbca78fb658b" + +[[package]] +name = "zune-jpeg" +version = "0.5.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "27bc9d5b815bc103f142aa054f561d9187d191692ec7c2d1e2b4737f8dbd7296" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6272a11 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,3 @@ +[workspace] +members = ["apps/api", "crates/chusql-driver"] +resolver = "3" diff --git a/PLAN.md b/PLAN.md new file mode 100755 index 0000000..0afcd7e --- /dev/null +++ b/PLAN.md @@ -0,0 +1,225 @@ +# ChuBlog — 项目规划 + +> 状态:规划草案 v1.0 · 2026-10-15 +> 定位:自托管个人技术博客;Markdown 文件是内容源,Git 管理版本,ChuSQL 提供可重建的内容索引。 + +## 1. 目标与原则 + +- 面向个人作者,支持公开阅读、文章归档、标签、代码高亮、目录、SEO、RSS 和深色模式。 +- 管理员可在经过认证的后台上传 Markdown、预览、修改元数据、发布、撤回与删除文章;也支持本地 Git 工作流。 +- **Git 仓库内的 Markdown 与资源文件是唯一权威内容源**;ChuSQL 不保存唯一一份正文,只维护可重建索引。 +- 未授权用户不得发布页面、提交到内容仓库或触发任意服务器 Git 命令。 +- 保持 MVP 小而可靠;不引入公共用户注册、评论、插件市场、队列/微服务或复杂角色系统。 +- ChuSQL 暂不视为已通过生产验证的数据库;博客即使索引丢失,也能从 Git 恢复文章。 + +## 2. 技术栈 + +| 层 | 选型 | 职责 | +| --- | --- | --- | +| 站点 | Astro + TypeScript | 内容页面、按需服务端渲染、SEO、RSS | +| 样式 | Tailwind CSS | 响应式设计、暗色模式 | +| Markdown | Astro Markdown 管线(remark/rehype、Shiki) | Markdown/Frontmatter 解析、代码高亮 | +| 后端 | Rust + Axum + Tokio + Serde | 管理员接口、认证、上传校验、Git 操作、索引同步 | +| 数据库 | ChuSQL | 已发布文章元数据、标签、搜索索引(按当前能力逐步实现) | +| DB 客户端 | `chusql-rs`(独立 crate) | ChuSQL 网络协议连接、查询、错误处理 | +| 反向代理 | Caddy | HTTPS、请求体限制、路由 | +| 运行 | Debian + Docker Compose | 管理服务与持久卷 | +| 版本控制 | Git | 文章与图片的版本记录、审计与恢复 | + +不自行编写 Markdown 语法解释器。MVP 禁止文章中的原始 HTML 或采用严格清理策略;默认不启用任意 MDX/JS 组件,因为上传文档不能成为服务器执行代码的入口。 + +## 3. 系统架构 + +```text +读者浏览器 ── HTTPS ── Caddy ── Astro(公共页面) + │ + ├─ 只读文章文件 / content(挂载) + └─ Axum 只读 API(必要时)── ChuSQL(索引) + +管理员浏览器 ── HTTPS ── Caddy ── Axum(后台 API) + │ + ├─ 管理员认证 / CSRF / 限流 + ├─ Markdown 校验及暂存 + ├─ 受控 Git 内容仓库 + └─ 索引同步 ── chusql-rs ── ChuSQL + +管理员本地编辑器 ── Git 受保护分支 / 仓库同步流程 ── 内容仓库 +``` + +- 只暴露 80/443 TCP;Axum、Astro 与 ChuSQL 使用私有 Docker 网络。 +- 现有 P2P 服务可独立继续使用 UDP 7001,互不影响。 +- **不允许用户请求决定任意磁盘路径、Git 仓库地址、Git 参数或 shell 命令。** +- Astro 若需上传后立即生效,使用按需 SSR 读取受控的内容快照;不要依赖仅构建时扫描的静态内容集合。若改用纯静态生成,则明确采用“提交后重新构建并原子部署”的发布策略。MVP 选择 **SSR + 已发布内容快照**。 + +## 4. 内容模型与存储布局 + +```text +chublog/ +├── apps/ +│ ├── web/ # Astro 站点 +│ └── api/ # Axum 后台与只读 API +├── crates/ +│ └── chusql-rs/ # 若尚无独立仓库,可先工作区开发再拆分 +├── content/ # 独立 Git 仓库或子目录(受控工作树) +│ ├── posts/ +│ │ └── haskell-monad/ +│ │ ├── index.md +│ │ └── cover.webp +│ └── .gitignore +├── deploy/ +│ ├── compose.yaml +│ └── Caddyfile +└── PLAN.md +``` + +文章 Frontmatter 规范示例: + +```md +--- +title: "Haskell Monad 入门" +slug: "haskell-monad" +date: "2026-10-15" +updated: "2026-10-15" +status: "published" +tags: ["Haskell", "Functional Programming"] +description: "Monad 的基础概念" +--- + +# Haskell Monad 入门 + +正文…… +``` + +- `slug` 是唯一的 URL 标识,仅允许小写英文字母、数字、短横线,校验长度和保留名称。 +- 文件路径由服务端基于已校验 slug 固定计算;禁止 `../`、绝对路径、符号链接和路径穿越。 +- `status` 取 `draft | published`;草稿不可被未授权访问或从公开索引枚举。 +- 图片仅允许必要类型(如 PNG/JPEG/WebP),按真实文件内容与尺寸校验,限制单文件和文章总大小。SVG 暂不开放后台上传。 +- 内容文件和 Frontmatter 是权威信息;ChuSQL 中的记录可由全量扫描重新建立。 + +## 5. Git 内容管理工作流 + +### 5.1 后台上传与发布 + +1. 管理员登录,上传 `.md` 与附属图片;后端检查会话、CSRF 与限流。 +2. 在隔离的暂存目录校验文件、解析 Frontmatter、检查重复 slug、限制大小和 Markdown 语法范围。 +3. 展示预览;管理员明确点击“发布”。预览默认不公开。 +4. 服务端通过受控文件操作写入仓库指定目录,创建 Git commit(提交者为固定服务身份,日志可附管理员操作 ID)。 +5. 提交成功后生成/切换已发布内容快照,并更新 ChuSQL 索引;公开页面仅读取已发布快照。 +6. 若索引更新失败,保留 Git 提交和待同步状态,由可重复执行的扫描任务恢复,不得静默显示“全部成功”。 + +### 5.2 本地 Git 编写 + +- 管理员可以在本地仓库编辑 Markdown,向受保护的主分支推送,或通过 PR 合并。 +- 推荐在 CI 检查 Frontmatter、路径、大小、图片、链接及危险内容;只有已认证维护者可合并。 +- 服务器采用**只读拉取/受控部署**,不接受互联网匿名推送;可使用带最小权限的部署凭证。 +- **双入口写入存在冲突风险**:MVP 应只开放一个写入主入口。建议首先采用“后台上传写入本地 Git 仓库”,后续引入远端同步时再明确由 CI 部署作为唯一发布入口;或者反过来完全让后台创建 PR。禁止两个入口不加协调地向同一工作树写入。 + +### 5.3 更新、撤回、删除 + +- 更新是新的 Git commit;保留文章稳定 slug,必要时配置显式重定向。 +- 撤回把 `status` 改为 `draft`,新快照不可公开访问。 +- 删除从当前版本移除,但 Git 历史可能仍含内容;对于秘密泄露需要额外执行历史清理与凭证轮换,不能认为删除 commit 就彻底清除。 +- 上传的草稿与私有资源必须与公开静态目录隔离,避免直链泄露。 + +## 6. 身份认证与抗垃圾页面 + +**MVP 采用单管理员、无公开注册、无公开投稿。** 未登录者没有任何创建、编辑、上传或 Git 写权限。这比单纯依靠验证码或限流更能从根源上避免垃圾页面泛滥。 + +安全控制: + +1. 管理员账户配置在受保护的服务端环境或安全存储中;口令以 Argon2id 等成熟密码哈希存储,绝不保留明文。 +2. 登录成功后发放服务端会话;Cookie 使用 `HttpOnly`、`Secure`、`SameSite`,设置到期时间和退出失效机制。生产建议支持 TOTP 2FA 或使用成熟身份代理实现 MFA。 +3. 所有状态变更接口强制验证身份与 CSRF(包括上传、提交、发布和删除);校验 `Origin`,设置合理 CORS 策略。 +4. 登录端点按 IP 与账户维度限流、失败退避和审计;上传限制文件大小、数量、总磁盘配额、请求频率、并发和执行耗时。 +5. 反向代理和 API 分层限制请求体大小;服务仅接受预期 MIME 与文件结构;禁止 HTML/脚本执行、可疑链接协议与不受控资源嵌入。 +6. Git 命令通过固定参数的进程 API 执行,**不用拼接 shell 字符串**;Git 工作树不允许访问任意仓库 hook,服务运行身份权限最小化。 +7. 公开内容启用合理 CSP、HTML 清理、链接安全属性;后台不暴露到不必要的外部域名。 +8. 采用日志审计:谁在什么时候上传、预览、发布、撤回、删除,以及对应 commit hash;不要记录口令或会话令牌。 + +若以后支持访客投稿:所有投稿必须进入**待审核区**,经管理员人工批准后才能进入正式 Git 分支;不得让访客直接触发公开发布。 + +## 7. API 草案 + +| 方法 | 路径 | 权限 | 说明 | +| --- | --- | --- | --- | +| `POST` | `/api/admin/login` | 公共、限流 | 管理员登录 | +| `POST` | `/api/admin/logout` | 管理员 + CSRF | 注销会话 | +| `GET` | `/api/posts` | 公共 | 查询已发布文章元数据 | +| `GET` | `/api/posts/:slug` | 公共 | 已发布文章信息(或由 Astro 直接读取文件) | +| `POST` | `/api/admin/uploads` | 管理员 + CSRF | 上传临时文件、验证 | +| `POST` | `/api/admin/posts/preview` | 管理员 + CSRF | 安全预览 | +| `POST` | `/api/admin/posts/publish` | 管理员 + CSRF | 固定目录落盘并 Git 提交 | +| `PATCH` | `/api/admin/posts/:slug` | 管理员 + CSRF | 更新元数据/内容 | +| `POST` | `/api/admin/posts/:slug/unpublish` | 管理员 + CSRF | 撤回文章 | +| `DELETE` | `/api/admin/posts/:slug` | 管理员 + CSRF | 删除当前版本 | +| `POST` | `/api/admin/index/rebuild` | 管理员 + CSRF | 从 Git 内容重建 ChuSQL 索引 | + +API 路由仅为计划,后续以实现的最小接口为准;对发布、更新、删除操作加互斥与幂等处理,防止并发提交损坏仓库状态。 + +## 8. ChuSQL 集成与一致性 + +MVP 仅需要 `posts` 元数据与 `tags`,字段例如 `slug, title, description, status, published_at, updated_at, path, git_commit`。字段类型、DDL、过滤和分页查询应按现有 ChuSQL 协议确定,不能预设兼容 PostgreSQL 或 SQLx。 + +- `chusql-rs` 第一版支持:连接、超时、查询/执行、结果与类型映射、错误处理、断线识别、集成测试。 +- 不依赖目前未实现或未经验证的 ChuSQL 多语句事务;应用端以 Git 快照为权威,数据库仅做派生索引。 +- 每次发布记录 Git commit hash,索引带对应内容版本号。建立独立的 `rebuild-index` 命令与定期一致性检查。 +- ChuSQL 不可用时,已生成的公开内容快照仍能被读取;列表、标签等可从文件快照构建简易只读索引。禁止在数据库错误时覆盖或删除文章文件。 +- 上传发布时采用严格顺序和可恢复状态:校验 → 暂存 → Git commit → 更新已发布快照 → 尝试索引同步;出错后可依据 commit 重新同步。 +- 文章页面不要因 ChuSQL 宕机而泄露内部错误或凭证。 + +## 9. 实施阶段与验收 + +### Phase 0 — 先验证接口 + +- [ ] 检查 ChuSQL 服务端网络协议、查询语法和持久化行为。 +- [ ] 决定 Astro SSR 在容器内读取哪个受控内容目录。 +- [ ] 确定 Git 仓库部署路径、最小权限和快照切换方式。 + +### Phase 1 — 纯文件博客 MVP + +- [ ] Astro 页面:首页、文章详情、标签、归档、404、暗色模式。 +- [ ] Markdown + Frontmatter 校验、Shiki 代码高亮、文章目录。 +- [ ] Astro SSR 读取已发布内容快照;提供 RSS、sitemap 和 SEO 元信息。 +- [ ] 测试异常文件、超大文件和 XSS 内容不会被执行。 + +### Phase 2 — 后台 + Git 发布 + +- [ ] 单管理员认证、会话与 CSRF。 +- [ ] 上传、预览、发布、编辑、撤回、删除。 +- [ ] 每次发布产生可追踪的 Git commit;冲突与重复提交有明确错误反馈。 +- [ ] 通过限流、文件配额、路径校验和无公开注册测试。 + +### Phase 3 — ChuSQL 驱动与索引 + +- [ ] 开发 `chusql-rs` 最小客户端及协议测试。 +- [ ] 建立文章元数据表、查询接口与索引同步。 +- [ ] 完成从 Git 仓库重建索引的命令与故障注入测试。 +- [ ] 验证 ChuSQL 故障期间公开阅读不中断、恢复后索引一致。 + +### Phase 4 — 部署与运维 + +- [ ] Docker Compose、Caddy TLS、仅开放必要公网端口。 +- [ ] 持久化 Git 内容与资源;数据库卷、日志独立管理。 +- [ ] 备份 Git 仓库(含远端镜像)与上传资源;定期进行真实恢复演练。 +- [ ] 监测磁盘、接口错误、登录失败、索引同步延迟。 +- [ ] 更新依赖、最小容器权限、限制容器挂载与网络访问。 + +**MVP 完成判定**:未经认证无法写入或发布;管理员可上传并发布一篇 Markdown,页面即刻可读,Git 中有对应提交;重启容器后内容存在;故意停止 ChuSQL 后仍可浏览已发布文章;重新建立数据库后可重建索引;恶意路径、超大文件和脚本注入测试均不能成功。 + +## 10. 生产就绪与风险边界 + +- **文章内容生产可用性**来自 Git、可恢复的文件快照、访问控制、备份及恢复演练,不取决于 ChuSQL 当前成熟度。 +- **ChuSQL 仍按实验性组件管理**:在事务原子性、并发控制、进程崩溃恢复、备份/恢复、认证与长时间稳定性未经验证前,不作为任何不可重建数据的唯一存储。 +- 文件与数据库之间不追求假想的跨系统原子事务,而采用幂等同步和可回放 Git 提交恢复一致性。 +- 不把管理员凭证、Git 写入凭证或 ChuSQL 密码写入 Git 仓库、镜像层和日志。 + +## 11. 暂不纳入 MVP + +- 访客注册、公开投稿、评论、点赞与复杂权限角色。 +- 前端在线富文本编辑器、协同编辑和复杂草稿工作流。 +- 全文搜索引擎、消息队列、分布式部署、对象存储、多租户。 +- 自建 Markdown 解释器、通用插件机制和 ChuSQL ORM/SQLx 适配。 + +## 12. 近期第一步 + +优先确认 ChuSQL 现有协议,同时创建 Astro 静态界面与 Axum 认证/上传原型。先跑通**管理员上传 → 校验 → Git 提交 → 公开页面读取文件**,之后再接入 ChuSQL 元数据索引。这样即使数据库研发推迟,博客仍可独立推进。 diff --git a/README.md b/README.md new file mode 100644 index 0000000..24491f1 --- /dev/null +++ b/README.md @@ -0,0 +1,126 @@ +# ChuBlog + +Rust/Axum 博客后端。文章作者在本地编辑 Markdown,通过受限 SSH Git 推送发布。注册、登录、权限管理使用 HTTP API;文章仅通过 Git 修改。ChuSQL 使用项目内复制的 `crates/chusql-driver`,只存可重建索引。 + +本轮按最新需求只实现后端,没有前端或在线文章管理。`PLAN.md` 保留原始规划,当前范围与后续安排见 [TODO.md](TODO.md)。 + +## 项目与运行 + +```text +workspace/ +├── ChuBlog/ # 本项目 +│ └── crates/chusql-driver/ # 项目内的驱动副本 +└── ChuSQL/ # 现有数据库及已编译 Linux 产物 +``` + +开发检查: + +```sh +cargo test --all-targets +cargo fmt --check +cargo clippy --all-targets -- -D warnings +``` + +部署基于 Debian 13,同架构构建 API 和 ChuSQL 后,组装运行镜像: + +```sh +# ChuSQL 尚未编译时,先按其项目说明构建 Rust 存储库、server 与 bootstrap。 +python3 scripts/prepare-deploy.py +cp .env.example .env +chmod 600 .env +# 从标准输入输入管理员口令,Ctrl-D 结束;不要把明文写进命令行历史。 +./deploy/bin/chublog-api hash-password +# 将输出以单引号包裹写入 .env 的 ADMIN_PASSWORD_HASH;同时配置域名及两份不同的随机 DB 口令。 +docker compose --env-file .env -f deploy/compose.yaml up -d --build +# db 启动就绪后,创建 chublog 库和非超级用户 chublog_index;失败时检查日志后重试。 +docker compose --env-file .env -f deploy/compose.yaml run --rm db-init +``` + +构建时若 Debian 官方下载源较慢,可在 `.env` 配置 `DEBIAN_MIRROR=mirrors.ustc.edu.cn`,仍使用 Debian 签名校验。 + +`prepare-deploy.py` 复制相邻 ChuSQL 的既有编译产物,不修改该项目;没有产物会明确失败。跨平台部署须先在目标 Debian/架构构建,不能复制 macOS、Windows 或其他架构二进制。镜像不含编译器、源码、`.env` 或密码文件。首次启动从环境中的哈希创建管理员,后续账号与权限以持久文件为准,修改初始哈希不会覆盖已有账户。 + +Caddy 根据 `SITE_ADDRESS` 提供 HTTPS 和 API 反向代理;`PUBLIC_ORIGIN` 必须与外部站点完全一致,不带结尾斜杠。HTTP 仅对 localhost 开发场景允许。只需公网 TCP 80/443 和 Git SSH 2222(可改);主机管理 SSH 另行管理,数据库和 API 不映射宿主端口。既有 UDP 7001 不受影响。参考 [Caddy 请求体限制](https://caddyserver.com/docs/caddyfile/directives/request_body) 与 [反向代理文档](https://caddyserver.com/docs/caddyfile/directives/reverse_proxy)。 + +## 用户与发布授权 + +完整接口见 [docs/API.md](docs/API.md)。 + +1. 注册:`POST /api/auth/register`,正文 `{ "username": "alice", "password": "至少12字节的口令" }`。默认角色 `reader`,绝不接受客户端指定角色。`ALLOW_REGISTRATION=false` 可关闭注册。 +2. 登录:`POST /api/auth/login`,相同字段。浏览器保存 HttpOnly Cookie;返回 `csrf`。所有修改请求必须携带站点 `Origin` 和 `X-CSRF-Token`。 +3. 本地生成 `ssh-keygen -t ed25519`;登录后 `PUT /api/account/ssh-key` 提交 `{ "public_key": "ssh-ed25519 AAAA..." }`,只交公钥。 +4. 管理员登录后调用 `PATCH /api/admin/users/alice`,正文 `{ "enabled": true, "role": "publisher", "prefixes": ["alice-"] }`。不同有效发布者的前缀不能重叠。 +5. 此时 SSH 才接受该用户的密钥;`reader`、停用用户、未绑密钥的用户均不能访问 Git。管理员也需显式分配前缀和公钥才能发布。 + +授权与密钥独立:知道服务器地址、有网站账户、上传了公钥都不足以推送。设回 `reader` 或 `enabled:false` 会撤回发布授权,并使现有网站会话失效。禁止管理员停用自己或取消自己的管理角色。 + +## 本地 Git 工作流 + +先通过可信渠道核对服务器 Ed25519 主机密钥指纹;不要禁用主机密钥检查。主机密钥保存在独立持久卷,重建容器不会更换。所有发布者使用同一个 OS 账户 `git`,身份由对应公钥的强制命令绑定,不由 Git author/email 决定。 + +```sh +# ~/.ssh/config +Host chublog + HostName blog.example.com + Port 2222 + User git + IdentityFile ~/.ssh/id_ed25519 + IdentitiesOnly yes + +# 首次发布,远端是空仓库时: +git init --initial-branch=main my-articles +cd my-articles +git remote add origin chublog:content.git +# 创建 posts/alice-first/index.md,规范参考 content/posts/welcome/index.md。 +# 样例 slug welcome 需要改成自己获授权的 alice-first。 +git add posts/alice-first +git commit -m 'Publish first article' +git push -u origin main + +# 已有内容时先 clone;每轮编辑前拉取最新内容。 +git clone chublog:content.git +cd content +git pull --ff-only +# 编辑 posts/alice-first/index.md +git add posts/alice-first +git commit -m 'Update article' +git push +``` + +MVP 每次只接受 **一个直接继承远端 main 的提交**,首次仅允许一个根提交。禁止 merge、force push、删除 main、标签及其他分支;本地有多次提交时先整理为单次提交,再拉取/变基到最新远端。发生并发冲突时,拉取并解决后重试。 + +`status: published` 发布,改为 `draft` 撤回,删除文章目录则删除当前版本。图片置于同一目录,Markdown 使用 `![说明](cover.png)`,不允许外链图片。API 在同步后读取新内容,一般约 2 秒;索引重建期间可能延迟到约 32 秒。`GET /api/admin/status` 查看已导入与已索引 commit;Git 接受只表示接收校验完成,尚不代表索引同步成功。 + +**授权发布者可以 clone 全部文章仓库及历史,包括其他发布者的草稿。** 草稿只对公开 HTTP 隔离,当前仓库不提供发布者之间的草稿保密;不要提交秘密或私钥。删除/撤回不能抹掉 Git 历史。 + +## 内容与隔离边界 + +- 唯一内容入口是 `posts//index.md` 和同目录 PNG/JPEG/WebP;禁止额外文件、目录、符号链接、可执行位、子模块和 LFS 指针。 +- Markdown 上限 256 KiB;图片单张 2 MiB、最多 12 张、尺寸 4096×4096;文章合计 6 MiB;快照原始文件合计 64 MiB、最多 1000 篇。 +- Git 输入 pack 上限 8 MiB;接收仓库达到 256 MiB 后拒绝新 SSH 会话,需要管理员离线维护。此阈值是应用检查,生产还应为 Docker 数据盘配置真实文件系统配额。 +- 使用成熟 Markdown 解析器,拒绝原始 HTML、危险链接协议、外链图片、控制符和双向隐藏字符;代码块是惰性文本,绝不执行 MDX、JS、仓库脚本、构建任务或上传内容。 +- 仅支持 Ed25519 公钥;拒绝把 `authorized_keys` 选项当作公钥上传。SSH 只接受精确的 `git-receive-pack 'content.git'` / `git-upload-pack 'content.git'`,没有 shell、SCP/SFTP、TTY、转发、隧道、用户环境变量。 +- 接收仓库由 UID 10002 管理;API 为独立 UID 10001,正式仓库/用户密码文件不挂载给 SSH 容器;SSH 只读授权文件和只读固定 hook。接收内容再次验证后才导入 API 自己的 Git 仓库。 +- HTTP 请求体 8 KiB;注册/登录合计全局 10 次/分钟,修改操作全局 60 次/分钟;密码哈希并发最多 2。1000 用户、1000 个有效会话上限,一小时固定会话期限。全局限制偏保守,后续可细化 IP/账户限制以改善公平性。 +- 统计不收集 IP/访客身份:公开文章数、标签数、Markdown 字节数、启动以来文章 API 请求数。请求数不是独立访客数,重启后清零。 + +服务器只返回 JSON Markdown 与图片;后续前端必须把元数据当文本输出,并继续禁用原始 HTML、MDX和危险链接,不能将接口字段直接作为 HTML 执行。隔离不能替代 Git/OpenSSH/解码库的持续安全更新。 + +## 持久化、故障和恢复 + +`content` 卷包含正式 `content.git`、`users.json` 和公开 `published.json`;`authorization` 卷是独立发布授权与公钥;`ingress` 是 SSH 接收仓库;`ssh-host-keys` 保留服务器身份。数据库和数据库日志为独立卷。日志输出 stdout,由 Docker 分卷轮换,不记录口令、会话或私钥。 + +文章索引失败不会修改 Git 或阻塞 HTTP 阅读,后台约每分钟重试并检查索引是否丢失。恢复数据库后可自动重建;离线命令为 `chublog-api rebuild-index`(必须停止同一数据卷上的 API 进程后运行,防止并发),或等待在线自动重试。`indexed_commit:null` 表示尚无已确认可用的索引,旧值表示尚未追平当前内容。应用不依赖 ChuSQL 事务。 + +备份必须同时包含正式 Git、用户文件、授权卷、SSH 主机密钥;备份包含密码哈希,须加密并限制权限。接收 Git 可额外镜像备份。恢复时先停止写入,从同一备份恢复各卷,再启动;不要只恢复 ChuSQL,也不要直接向正式仓库推送。应用启动从 Git 重新校验并生成快照。进程重启会注销所有会话。 + +本实现对文件写入执行原子替换与 fsync,Git 引用采用受控更新;磁盘故障、主机断电和备份恢复仍需真实环境演练。不要对含正式数据的项目执行 `docker compose down -v`。 + +## 联调 + +```sh +python3 scripts/prepare-deploy.py +python3 scripts/smoke.py +``` + +联调使用独立随机命名的 Compose 项目、本机 18080/18443/12222 端口,测试结束清理自己创建的容器与卷。覆盖真实 SSH 身份与授权、非法推送、发布撤回、ChuSQL 索引、断库阅读、重启与撤权。该测试会拉取基础镜像,需要 Docker 可用。 diff --git a/TODO.md b/TODO.md new file mode 100644 index 0000000..47e0a70 --- /dev/null +++ b/TODO.md @@ -0,0 +1,64 @@ +# ChuBlog — 后续待办计划 + +更新:2026-10-15。本文依据最新需求划分后续工作;`PLAN.md` 保留原始总体规划。当前只做后端,Git 是唯一文章写入入口。 + +## 当前已实现 + +- [x] Rust/Axum API:公开文章列表、详情、图片、基础统计、健康接口。 +- [x] 用户注册、登录、退出、修改密码;Argon2id、服务端会话、Cookie、Origin/CSRF 校验。 +- [x] reader / publisher / admin 权限;注册不能自选权限,管理员单独审批发布权、停用用户,修改权限使已有会话失效。 +- [x] 用户上传/轮换自己的 Ed25519 公钥,公钥存在与具有发布权分别检查。 +- [x] OpenSSH 强制命令:仅固定仓库的 Git push/clone;禁用 shell、SFTP/SCP、TTY、代理/端口转发、隧道与用户环境。 +- [x] 发布者 slug 前缀授权,不允许重叠授权或修改其他命名空间。 +- [x] pre-receive 校验 Git quarantine 内的候选提交:单次一个线性提交、仅 main、禁止删除分支、强推、标签及其他分支。 +- [x] Markdown Frontmatter、路径、文件模式、大小配额、图片类型/内容/尺寸与链接安全校验;拒绝原始 HTML、隐藏控制符、符号链接、可执行文件及额外文件。 +- [x] 独立 SSH 接收仓库与 API 正式仓库;从 Git 恢复公开快照,草稿不出现在公开 API/资源接口。 +- [x] 项目内 `crates/chusql-driver` 副本;ChuSQL 公开元数据/标签索引及失败自动重建,数据库不承担唯一内容或账户存储。 +- [x] Docker Compose、Caddy 反向代理、独立持久卷、容器资源限制、最小进程身份与网络隔离。 +- [x] 后端/驱动自动测试、真实容器 SSH 联调脚本、运行/API/架构说明。 + +## 第一优先级:部署环境验收 + +- [ ] 配置真实域名、DNS、服务器防火墙与 Caddy 公网证书,确认只有所需 80/443/文章 SSH 端口对外开放。 +- [ ] 从真实作者电脑验证 SSH 主机指纹、注册、公钥登记、管理员授权、首次推送和多人并发冲突处理。 +- [ ] 给 Docker 数据目录设置文件系统硬配额;用压缩炸弹、大量失败推送和连接洪泛验证空间、CPU、内存、超时及进程数边界。 +- [ ] 对正式 Git、用户哈希、授权文件、SSH 主机密钥制定加密备份与保留策略,执行完整恢复演练。 +- [ ] 验证断电/磁盘满时 Git 引用、快照、账户及权限文件的一致性;故障恢复后不保留过期发布权。 +- [ ] 在实际 ChuSQL 版本执行长期并发、进程崩溃恢复及索引故障注入验证;保持其派生索引定位。 +- [ ] 固定经过验证的镜像 digest,建立 Git/OpenSSH/Rust 依赖和基础系统安全更新流程。 + +验收:有完整恢复记录;恶意推送不能影响正式内容;撤权后不能再次推送;数据库宕机仍可阅读,恢复后索引 commit 与内容一致。 + +## 第二优先级:账户与权限完善 + +- [ ] 管理员 MFA(成熟身份代理或 TOTP),敏感授权操作二次验证。 +- [ ] 安全的管理员离线恢复/重置流程;用户密码找回需要先决定可信邮件或邀请渠道,禁止不验证身份的重置接口。 +- [ ] 每用户多把公钥、指纹展示、单钥撤销、最后使用时间;保留每钥身份绑定。 +- [ ] 在全局限流之上增加可信代理来源的 IP 与账户维度限制、失败退避,减轻公共认证桶被恶意占满的问题。 +- [ ] 注册开放策略、邀请注册或邮件验证;保留注册与发布审批的权限分离。 +- [ ] 持久化结构化审计,明确记录每次接受的 commit 对应站内用户与公钥指纹,并设置保留周期。当前 SSH 接收日志已有身份与 commit,API 导入日志只有 commit,尚无统一审计查询。 +- [ ] 发布者之间的草稿隔离需求确认:当前授权作者能 clone 全仓库及历史。如需作者之间保密,改用独立收稿仓库,再统一发布。 + +## 第三优先级:Astro + TypeScript 阅读前端 + +- [ ] SSR 读取后端已发布 API:首页、文章详情、标签、归档、404、暗色模式与移动端排版。 +- [ ] 使用 Astro Markdown/remark/rehype 和 Shiki:持续禁用原始 HTML/MDX,并清理输出,元数据必须作为文本转义。 +- [ ] 文章目录、SEO、canonical、Open Graph、RSS、sitemap、robots。 +- [ ] 注册/登录/账户页面、SSH 公钥设置和管理员发布授权页面;不增加在线文章写入口。 +- [ ] 无障碍、错误状态、限流提示、安全 CSP 与浏览器端 XSS 回归验证。 + +验收:作者只需在本地编辑并 push,阅读端自动看到更新;草稿及资源无法由未登录访问者枚举或直链读取。 + +## 第四优先级:发布和运维体验 + +- [ ] 发布状态查询:区分 Git 已接受、内容已导入、索引已同步,向作者提供 commit 级诊断。 +- [ ] 支持有上限的多次线性提交推送,逐个验证新增历史;保留对 merge/force push 的明确策略。 +- [ ] 目录配额预警、安全 GC 与维护窗口;禁止在 SSH 请求里运行任意维护命令。 +- [ ] 扩充现有 commit/行数对账为完整字段与标签对账;增加磁盘/登录失败/索引延迟/导入失败告警,以及受控恢复工具。 +- [ ] API 分页与条件缓存:保留撤回后不可继续公开访问的语义。 +- [ ] 更完整的统计:先定义访问口径与隐私策略,再决定是否持久化每日聚合;当前请求计数不是独立访客数,重启清零。 +- [ ] 外部资源链接检查、slug 重定向与图片元数据清理;不能因此在服务器执行作者提供的命令。 + +## 后续再评估 + +在线编辑/上传/预览、全文检索、评论、多副本和更复杂的角色。若引入在线编辑,应先明确其如何经过同一授权、校验与发布协调流程,不能在现有 Git 仓库上新增未经协调的第二个写入口。 diff --git a/apps/api/Cargo.toml b/apps/api/Cargo.toml new file mode 100644 index 0000000..c1dd137 --- /dev/null +++ b/apps/api/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "chublog-api" +version = "0.1.0" +edition = "2024" + +[dependencies] +anyhow = "1" +argon2 = "0.5" +axum = "0.8" +base64 = "0.22" +chrono = { version = "0.4", features = ["serde"] } +chusql-driver = { path = "../../crates/chusql-driver" } +fs2 = "0.4" +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "webp"] } +rand = "0.8" +serde = { version = "1", features = ["derive"] } +serde_json = "1" +serde_yaml = "0.9" +tokio = { version = "1", features = ["full"] } +pulldown-cmark = "0.13" +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +[dev-dependencies] +tempfile = "3" +tower = { version = "0.5", features = ["util"] } diff --git a/apps/api/src/accounts.rs b/apps/api/src/accounts.rs new file mode 100644 index 0000000..0f3ba4b --- /dev/null +++ b/apps/api/src/accounts.rs @@ -0,0 +1,657 @@ +use crate::{ApiError, ApiResult, App, bad, internal, origin, ssh}; +use anyhow::{Context, Result, ensure}; +use argon2::{ + Argon2, PasswordHash, PasswordHasher, PasswordVerifier, + password_hash::{SaltString, rand_core::OsRng}, +}; +use axum::{ + Json, + extract::{Path, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, +}; +use base64::{ + Engine, + engine::general_purpose::{STANDARD, URL_SAFE_NO_PAD}, +}; +use rand::RngCore; +use serde::{Deserialize, Serialize}; +use serde_json::{Value, json}; +use std::{ + collections::{BTreeMap, VecDeque}, + path::Path as FilePath, + sync::Arc, + time::{Duration, Instant}, +}; +use tokio::io::AsyncWriteExt; + +#[derive(Clone, Copy, Debug, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Role { + Reader, + Publisher, + Admin, +} +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Grant { + pub enabled: bool, + pub role: Role, + pub prefixes: Vec, + pub ssh_key: Option, +} +#[derive(Serialize, Deserialize)] +struct Credential { + hash: String, +} +struct Session { + username: String, + csrf: String, + expires: Instant, +} +pub struct Accounts { + credentials: BTreeMap, + pub grants: ssh::Config, + sessions: BTreeMap, + attempts: VecDeque, + changes: VecDeque, +} + +pub async fn atomic(path: &FilePath, bytes: &[u8], mode: u32) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let tmp = path.with_extension("tmp"); + let mut file = tokio::fs::OpenOptions::new() + .create(true) + .truncate(true) + .write(true) + .mode(mode) + .open(&tmp) + .await?; + file.write_all(bytes).await?; + file.sync_all().await?; + tokio::fs::set_permissions(&tmp, std::fs::Permissions::from_mode(mode)).await?; + tokio::fs::rename(&tmp, path).await?; + let directory = std::fs::File::open(path.parent().context("文件缺少父目录")?)?; + directory.sync_all()?; + Ok(()) +} +fn username(value: &str) -> Result<()> { + ensure!( + (3..=32).contains(&value.len()) + && value + .bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-') + && value.starts_with(|c: char| c.is_ascii_lowercase()), + "用户名必须为 3–32 位小写字母、数字或短横线,以字母开头" + ); + Ok(()) +} +fn password(value: &str) -> Result<()> { + ensure!( + (12..=1024).contains(&value.len()), + "口令必须为 12–1024 字节" + ); + Ok(()) +} +pub async fn hash_password(value: String) -> Result { + password(&value)?; + tokio::task::spawn_blocking(move || { + Argon2::default() + .hash_password(value.as_bytes(), &SaltString::generate(&mut OsRng)) + .map(|hash| hash.to_string()) + .map_err(|e| anyhow::anyhow!(e.to_string())) + }) + .await? +} +pub async fn load( + data: &FilePath, + config: &FilePath, + repository: std::path::PathBuf, +) -> Result { + let credentials: BTreeMap = + match tokio::fs::read(data.join("users.json")).await { + Ok(bytes) => serde_json::from_slice(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => BTreeMap::new(), + Err(error) => return Err(error.into()), + }; + let grants = match tokio::fs::read(config).await { + Ok(bytes) => serde_json::from_slice(&bytes)?, + Err(error) if error.kind() == std::io::ErrorKind::NotFound => ssh::Config { + repository, + publishers: BTreeMap::new(), + }, + Err(error) => return Err(error.into()), + }; + let mut accounts = Accounts { + credentials, + grants, + sessions: BTreeMap::new(), + attempts: VecDeque::new(), + changes: VecDeque::new(), + }; + if accounts.credentials.is_empty() { + let name = std::env::var("ADMIN_USERNAME").unwrap_or_else(|_| "admin".into()); + username(&name)?; + let hash = + std::env::var("ADMIN_PASSWORD_HASH").context("首次启动必须设置 ADMIN_PASSWORD_HASH")?; + let parsed = PasswordHash::new(&hash).map_err(|_| anyhow::anyhow!("管理员哈希格式无效"))?; + ensure!( + parsed.algorithm.as_str() == "argon2id", + "管理员哈希必须为 Argon2id" + ); + accounts + .credentials + .insert(name.clone(), Credential { hash }); + accounts.grants.publishers.insert( + name, + Grant { + enabled: true, + role: Role::Admin, + prefixes: vec![], + ssh_key: None, + }, + ); + atomic(config, &serde_json::to_vec_pretty(&accounts.grants)?, 0o644).await?; + atomic( + &data.join("users.json"), + &serde_json::to_vec(&accounts.credentials)?, + 0o600, + ) + .await?; + } + ensure!( + accounts + .grants + .publishers + .iter() + .any(|(name, g)| g.role == Role::Admin + && g.enabled + && accounts.credentials.contains_key(name)), + "至少需要一个启用的管理员" + ); + Ok(accounts) +} +fn rate(accounts: &mut Accounts) -> ApiResult<()> { + let now = Instant::now(); + while accounts + .attempts + .front() + .is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60)) + { + accounts.attempts.pop_front(); + } + // One global bucket also bounds attacks distributed across IPs and account names. + if accounts.attempts.len() >= 10 { + return Err(ApiError( + StatusCode::TOO_MANY_REQUESTS, + "认证请求过于频繁,请稍后再试".into(), + )); + } + accounts.attempts.push_back(now); + Ok(()) +} +fn token() -> String { + let mut bytes = [0; 32]; + rand::thread_rng().fill_bytes(&mut bytes); + URL_SAFE_NO_PAD.encode(bytes) +} +pub async fn authorize( + app: &App, + headers: &HeaderMap, + change: bool, + admin: bool, +) -> ApiResult { + let cookie = headers + .get(header::COOKIE) + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .split(';') + .filter_map(|p| p.trim().split_once('=')) + .find(|(name, _)| *name == "chublog_session") + .map(|(_, v)| v) + .unwrap_or(""); + let mut accounts = app.accounts.lock().await; + let session = accounts + .sessions + .get(cookie) + .filter(|s| s.expires > Instant::now()) + .ok_or_else(|| ApiError(StatusCode::UNAUTHORIZED, "请先登录".into()))?; + let grant = accounts + .grants + .publishers + .get(&session.username) + .filter(|g| g.enabled) + .ok_or_else(|| ApiError(StatusCode::FORBIDDEN, "用户已停用".into()))?; + if admin && grant.role != Role::Admin { + return Err(ApiError(StatusCode::FORBIDDEN, "需要管理员权限".into())); + } + if change { + origin(app, headers)?; + if headers.get("x-csrf-token").and_then(|v| v.to_str().ok()) != Some(session.csrf.as_str()) + { + return Err(ApiError(StatusCode::FORBIDDEN, "CSRF 校验失败".into())); + } + } + let username = session.username.clone(); + if change { + let now = Instant::now(); + while accounts + .changes + .front() + .is_some_and(|t| now.duration_since(*t) >= Duration::from_secs(60)) + { + accounts.changes.pop_front(); + } + if accounts.changes.len() >= 60 { + return Err(ApiError( + StatusCode::TOO_MANY_REQUESTS, + "修改请求过于频繁".into(), + )); + } + accounts.changes.push_back(now); + } + Ok(username) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct Login { + username: String, + password: String, +} +pub async fn register( + State(app): State>, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult> { + origin(&app, &headers)?; + if !app.registration { + return Err(ApiError(StatusCode::FORBIDDEN, "注册已关闭".into())); + } + username(&input.username).map_err(bad)?; + password(&input.password).map_err(bad)?; + { + let mut accounts = app.accounts.lock().await; + rate(&mut accounts)?; + if accounts.credentials.len() >= 1000 { + return Err(bad("用户配额已满")); + } + } + let _permit = app + .hash_slots + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?; + let hash = hash_password(input.password).await.map_err(internal)?; + let mut accounts = app.accounts.lock().await; + if accounts.credentials.contains_key(&input.username) { + return Err(ApiError(StatusCode::CONFLICT, "用户名不可用".into())); + } + if accounts.credentials.len() >= 1000 { + return Err(bad("用户配额已满")); + } + let mut grants = accounts.grants.clone(); + grants.publishers.insert( + input.username.clone(), + Grant { + enabled: true, + role: Role::Reader, + prefixes: vec![], + ssh_key: None, + }, + ); + atomic( + &app.ssh_config, + &serde_json::to_vec_pretty(&grants).map_err(internal)?, + 0o644, + ) + .await + .map_err(internal)?; + accounts.grants = grants; + accounts + .credentials + .insert(input.username.clone(), Credential { hash }); + if let Err(error) = atomic( + &app.data.join("users.json"), + &serde_json::to_vec(&accounts.credentials).map_err(internal)?, + 0o600, + ) + .await + { + accounts.credentials.remove(&input.username); + return Err(internal(error)); + } + tracing::info!(action = "register", user = %input.username); + Ok(Json(json!({"username": input.username, "role": "reader"}))) +} +pub async fn login( + State(app): State>, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult { + origin(&app, &headers)?; + username(&input.username).map_err(bad)?; + password(&input.password).map_err(bad)?; + let hash = { + let mut accounts = app.accounts.lock().await; + rate(&mut accounts)?; + accounts + .credentials + .get(&input.username) + .or_else(|| accounts.credentials.values().next()) + .map(|c| c.hash.clone()) + .ok_or_else(|| internal("无管理员"))? + }; + let _permit = app + .hash_slots + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?; + let verified = tokio::task::spawn_blocking(move || { + PasswordHash::new(&hash).ok().is_some_and(|h| { + Argon2::default() + .verify_password(input.password.as_bytes(), &h) + .is_ok() + }) + }) + .await + .map_err(internal)?; + let mut accounts = app.accounts.lock().await; + if !verified + || !accounts.credentials.contains_key(&input.username) + || !accounts + .grants + .publishers + .get(&input.username) + .is_some_and(|g| g.enabled) + { + tracing::warn!(action = "login_failed", user = %input.username); + return Err(ApiError(StatusCode::UNAUTHORIZED, "登录失败".into())); + } + let now = Instant::now(); + accounts + .sessions + .retain(|_, s| s.expires > now && s.username != input.username); + if accounts.sessions.len() >= 1000 { + return Err(bad("会话配额已满")); + } + let id = token(); + let csrf = token(); + accounts.sessions.insert( + id.clone(), + Session { + username: input.username.clone(), + csrf: csrf.clone(), + expires: now + Duration::from_secs(3600), + }, + ); + let cookie = format!( + "chublog_session={id}; Path=/; HttpOnly; SameSite=Strict; Max-Age=3600{}", + if app.secure { "; Secure" } else { "" } + ); + let mut response = Json(json!({"csrf": csrf, "username": input.username, "role": accounts.grants.publishers[&input.username].role})).into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&cookie).map_err(internal)?, + ); + tracing::info!(action = "login", user = %input.username); + Ok(response) +} +pub async fn me(State(app): State>, headers: HeaderMap) -> ApiResult> { + let name = authorize(&app, &headers, false, false).await?; + let accounts = app.accounts.lock().await; + let csrf = accounts + .sessions + .values() + .find(|s| s.username == name) + .map(|s| &s.csrf); + Ok(Json( + json!({"username": name, "permissions": accounts.grants.publishers[&name], "csrf": csrf}), + )) +} +pub async fn logout(State(app): State>, headers: HeaderMap) -> ApiResult { + let name = authorize(&app, &headers, true, false).await?; + app.accounts + .lock() + .await + .sessions + .retain(|_, s| s.username != name); + let mut response = StatusCode::NO_CONTENT.into_response(); + response.headers_mut().insert( + header::SET_COOKIE, + HeaderValue::from_str(&format!( + "chublog_session=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0{}", + if app.secure { "; Secure" } else { "" } + )) + .map_err(internal)?, + ); + Ok(response) +} +pub fn public_key(value: &str) -> Result { + ensure!( + value.len() < 512 && !value.contains(['\n', '\r']), + "公钥格式无效" + ); + let parts: Vec<_> = value.split_whitespace().collect(); + ensure!( + parts.len() >= 2 && parts[0] == "ssh-ed25519", + "仅支持 Ed25519 公钥,禁止 authorized_keys 选项" + ); + let bytes = STANDARD.decode(parts[1])?; + ensure!( + bytes.len() == 51 + && bytes[..4] == [0, 0, 0, 11] + && &bytes[4..15] == b"ssh-ed25519" + && bytes[15..19] == [0, 0, 0, 32], + "Ed25519 公钥内容无效" + ); + Ok(format!("ssh-ed25519 {}", STANDARD.encode(bytes))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct KeyInput { + public_key: String, +} +pub async fn set_key( + State(app): State>, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult> { + let name = authorize(&app, &headers, true, false).await?; + let key = public_key(&input.public_key).map_err(bad)?; + let mut accounts = app.accounts.lock().await; + if accounts + .grants + .publishers + .iter() + .any(|(user, grant)| user != &name && grant.ssh_key.as_deref() == Some(&key)) + { + return Err(bad("公钥已被其他用户绑定")); + } + let mut grants = accounts.grants.clone(); + grants + .publishers + .get_mut(&name) + .ok_or_else(|| bad("用户不存在"))? + .ssh_key = Some(key); + atomic( + &app.ssh_config, + &serde_json::to_vec_pretty(&grants).map_err(internal)?, + 0o644, + ) + .await + .map_err(internal)?; + accounts.grants = grants; + tracing::info!(action = "ssh_key_changed", user = %name); + Ok(Json( + json!({"status": "saved", "message": "密钥已保存,发布仍需管理员授权"}), + )) +} +pub async fn users(State(app): State>, headers: HeaderMap) -> ApiResult> { + authorize(&app, &headers, false, true).await?; + Ok(Json(json!(app.accounts.lock().await.grants.publishers))) +} +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct GrantInput { + enabled: bool, + role: Role, + prefixes: Vec, +} +pub async fn grant( + State(app): State>, + headers: HeaderMap, + Path(name): Path, + Json(input): Json, +) -> ApiResult> { + let actor = authorize(&app, &headers, true, true).await?; + ensure_prefixes(&input.prefixes).map_err(bad)?; + if input.role == Role::Publisher && input.prefixes.is_empty() { + return Err(bad("发布者必须分配文章前缀")); + } + let mut accounts = app.accounts.lock().await; + if !accounts + .grants + .publishers + .get(&actor) + .is_some_and(|g| g.enabled && g.role == Role::Admin) + { + return Err(ApiError(StatusCode::FORBIDDEN, "管理员权限已失效".into())); + } + if !accounts.credentials.contains_key(&name) { + return Err(ApiError(StatusCode::NOT_FOUND, "用户不存在".into())); + } + if actor == name && (input.role != Role::Admin || !input.enabled) { + return Err(bad("不能取消自己的管理权限或停用自己")); + } + for (other, grant) in &accounts.grants.publishers { + if other == &name || !grant.enabled || grant.role == Role::Reader { + continue; + } + if input.enabled + && input.role != Role::Reader + && input.prefixes.iter().any(|a| { + grant + .prefixes + .iter() + .any(|b| a.starts_with(b) || b.starts_with(a)) + }) + { + return Err(bad("发布前缀与其他用户重叠")); + } + } + let mut grants = accounts.grants.clone(); + let entry = grants + .publishers + .get_mut(&name) + .ok_or_else(|| bad("用户授权缺失"))?; + entry.enabled = input.enabled; + entry.role = input.role; + entry.prefixes = if input.role == Role::Reader { + vec![] + } else { + input.prefixes + }; + atomic( + &app.ssh_config, + &serde_json::to_vec_pretty(&grants).map_err(internal)?, + 0o644, + ) + .await + .map_err(internal)?; + accounts.grants = grants; + accounts + .sessions + .retain(|_, s| s.username != name || s.username == actor); + tracing::info!(action = "permission_changed", user = %name, administrator = %actor); + Ok(Json(json!({"status": "saved"}))) +} +fn ensure_prefixes(prefixes: &[String]) -> Result<()> { + ensure!( + prefixes.len() <= 8 + && prefixes.iter().all(|p| (2..=64).contains(&p.len()) + && p.ends_with('-') + && p.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')), + "前缀必须为 2–64 位小写英文、数字、短横线且以短横线结尾,最多 8 个" + ); + Ok(()) +} + +#[derive(Deserialize)] +#[serde(deny_unknown_fields)] +pub struct PasswordInput { + old_password: String, + new_password: String, +} +pub async fn change_password( + State(app): State>, + headers: HeaderMap, + Json(input): Json, +) -> ApiResult> { + let name = authorize(&app, &headers, true, false).await?; + password(&input.old_password).map_err(bad)?; + password(&input.new_password).map_err(bad)?; + let _permit = app + .hash_slots + .clone() + .try_acquire_owned() + .map_err(|_| ApiError(StatusCode::TOO_MANY_REQUESTS, "认证服务繁忙".into()))?; + let previous = app + .accounts + .lock() + .await + .credentials + .get(&name) + .ok_or_else(|| bad("用户不存在"))? + .hash + .clone(); + let hash = previous.clone(); + let verified = tokio::task::spawn_blocking(move || { + PasswordHash::new(&hash).ok().is_some_and(|h| { + Argon2::default() + .verify_password(input.old_password.as_bytes(), &h) + .is_ok() + }) + }) + .await + .map_err(internal)?; + if !verified { + return Err(ApiError(StatusCode::UNAUTHORIZED, "旧口令错误".into())); + } + let hash = hash_password(input.new_password).await.map_err(internal)?; + let mut accounts = app.accounts.lock().await; + if !accounts + .grants + .publishers + .get(&name) + .is_some_and(|g| g.enabled) + { + return Err(ApiError(StatusCode::FORBIDDEN, "用户已停用".into())); + } + let credential = accounts + .credentials + .get_mut(&name) + .ok_or_else(|| bad("用户不存在"))?; + if credential.hash != previous { + return Err(ApiError( + StatusCode::CONFLICT, + "口令已变更,请重新登录".into(), + )); + } + credential.hash = hash; + if let Err(error) = atomic( + &app.data.join("users.json"), + &serde_json::to_vec(&accounts.credentials).map_err(internal)?, + 0o600, + ) + .await + { + accounts.credentials.get_mut(&name).unwrap().hash = previous; + return Err(internal(error)); + } + accounts.sessions.retain(|_, s| s.username != name); + tracing::info!(action = "password_changed", user = %name); + Ok(Json( + json!({"status": "saved", "message": "请使用新口令重新登录"}), + )) +} diff --git a/apps/api/src/content.rs b/apps/api/src/content.rs new file mode 100644 index 0000000..f979442 --- /dev/null +++ b/apps/api/src/content.rs @@ -0,0 +1,374 @@ +use anyhow::{Context, Result, ensure}; +use base64::{Engine, engine::general_purpose::STANDARD}; +use chrono::NaiveDate; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; +use tokio::{ + io::{AsyncReadExt, AsyncWriteExt}, + process::Command, +}; + +pub const MAX_MARKDOWN: usize = 256 * 1024; +pub const MAX_SNAPSHOT: usize = 64 * 1024 * 1024; + +#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum Status { + Draft, + Published, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Metadata { + pub title: String, + pub slug: String, + pub date: String, + pub updated: String, + pub status: Status, + pub tags: Vec, + pub description: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Asset { + pub name: String, + pub data: String, +} + +#[derive(Clone, Debug, Deserialize, Serialize)] +pub struct Post { + pub meta: Metadata, + pub markdown: String, + pub assets: Vec, +} + +#[derive(Clone, Default, Deserialize, Serialize)] +pub struct Snapshot { + pub commit: String, + pub posts: BTreeMap, +} + +pub fn slug(value: &str) -> Result<&str> { + ensure!( + (1..=80).contains(&value.len()) + && value + .bytes() + .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') + && !value.starts_with('-') + && !value.ends_with('-'), + "slug 仅允许 1–80 位小写英文、数字和中间短横线" + ); + ensure!( + ![ + "admin", "api", "posts", "tags", "archive", "rss", "sitemap", "404" + ] + .contains(&value), + "slug 为保留名称" + ); + Ok(value) +} + +pub fn parse(markdown: String, assets: Vec) -> Result { + ensure!(markdown.len() <= MAX_MARKDOWN, "Markdown 超过 256 KiB"); + let normalized = markdown.replace("\r\n", "\n"); + let rest = normalized + .strip_prefix("---\n") + .context("缺少 YAML Frontmatter")?; + let (yaml, _) = rest.split_once("\n---\n").context("Frontmatter 未闭合")?; + let meta: Metadata = serde_yaml::from_str(yaml).context("Frontmatter 无效")?; + slug(&meta.slug)?; + ensure!( + !meta.title.trim().is_empty() && meta.title.len() <= 240 && meta.description.len() <= 1000, + "标题或摘要长度无效" + ); + for date in [&meta.date, &meta.updated] { + let parsed = + NaiveDate::parse_from_str(date, "%Y-%m-%d").context("日期必须为 YYYY-MM-DD")?; + ensure!( + parsed.format("%Y-%m-%d").to_string() == *date, + "日期格式不规范" + ); + } + ensure!(meta.updated >= meta.date, "更新时间不能早于发布日期"); + ensure!( + meta.tags.len() <= 12 + && meta + .tags + .iter() + .all(|t| !t.trim().is_empty() && t.len() <= 80), + "标签无效" + ); + ensure!(assets.len() <= 12, "最多 12 张图片"); + let mut names = std::collections::HashSet::new(); + let mut total = normalized.len(); + for asset in &assets { + ensure!(names.insert(&asset.name), "图片名称重复"); + let bytes = decode_asset(asset)?; + total += bytes.len(); + } + ensure!(total <= 6 * 1024 * 1024, "文章及图片总计不得超过 6 MiB"); + validate_markdown(rest.split_once("\n---\n").unwrap().1, &assets)?; + Ok(Post { + meta, + markdown: normalized, + assets, + }) +} + +pub fn decode_asset(asset: &Asset) -> Result> { + let name = &asset.name; + ensure!( + name.len() <= 100 + && !name.starts_with('.') + && !name.contains("..") + && name + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"-_.".contains(&b)) + && name != "index.md", + "图片文件名无效" + ); + ensure!(asset.data.len() <= 3 * 1024 * 1024, "图片超过大小限制"); + let bytes = STANDARD.decode(&asset.data).context("图片编码无效")?; + ensure!(bytes.len() <= 2 * 1024 * 1024, "单张图片不得超过 2 MiB"); + let format = image::guess_format(&bytes).context("无法识别图片")?; + let extension = name.rsplit('.').next().unwrap_or("").to_ascii_lowercase(); + ensure!( + matches!( + (format, extension.as_str()), + (image::ImageFormat::Png, "png") + | (image::ImageFormat::Jpeg, "jpg" | "jpeg") + | (image::ImageFormat::WebP, "webp") + ), + "仅支持与扩展名一致的 PNG/JPEG/WebP" + ); + let reader = image::ImageReader::with_format(std::io::Cursor::new(&bytes), format); + let (width, height) = reader.into_dimensions().context("图片头无效")?; + ensure!( + width > 0 && height > 0 && width <= 4096 && height <= 4096, + "图片尺寸不得超过 4096×4096" + ); + image::load_from_memory_with_format(&bytes, format).context("图片内容损坏")?; + Ok(bytes) +} + +pub async fn git(repo: &Path, args: &[&str], input: Option<&[u8]>) -> Result> { + let mut command = Command::new("git"); + command + .arg("--no-pager") + .arg("-c") + .arg("core.hooksPath=/dev/null") + .arg("-c") + .arg("commit.gpgsign=false") + .arg("-C") + .arg(repo) + .args(args) + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/etc/chublog/gitconfig") + .env("GIT_AUTHOR_NAME", "ChuBlog") + .env("GIT_AUTHOR_EMAIL", "blog@localhost") + .env("GIT_COMMITTER_NAME", "ChuBlog") + .env("GIT_COMMITTER_EMAIL", "blog@localhost") + .env("GIT_INDEX_FILE", repo.join("publish.index")) + .stdin(Stdio::piped()) + .stdout(Stdio::piped()) + .stderr(Stdio::piped()) + .kill_on_drop(true); + let output = tokio::time::timeout(Duration::from_secs(15), async { + let mut child = command.spawn().context("启动 Git 失败")?; + let mut stdin = child.stdin.take().context("Git stdin")?; + let stdout = child.stdout.take().context("Git stdout")?; + let stderr = child.stderr.take().context("Git stderr")?; + let write = async { + stdin.write_all(input.unwrap_or_default()).await?; + drop(stdin); + Ok::<_, std::io::Error>(()) + }; + let read_out = async { + let mut out = Vec::new(); + stdout + .take(4 * 1024 * 1024 + 1) + .read_to_end(&mut out) + .await?; + Ok::<_, std::io::Error>(out) + }; + let read_err = async { + let mut err = Vec::new(); + stderr.take(65537).read_to_end(&mut err).await?; + Ok::<_, std::io::Error>(err) + }; + let (_, stdout, stderr) = tokio::try_join!(write, read_out, read_err)?; + ensure!( + stdout.len() <= 4 * 1024 * 1024 && stderr.len() <= 65536, + "Git 输出超过限制" + ); + let status = child.wait().await?; + Ok::<_, anyhow::Error>(std::process::Output { + status, + stdout, + stderr, + }) + }) + .await + .context("Git 操作超时")??; + ensure!( + output.status.success(), + "Git 操作失败: {}", + String::from_utf8_lossy(&output.stderr) + ); + Ok(output.stdout) +} + +pub async fn init(data: &Path) -> Result { + tokio::fs::create_dir_all(data).await?; + let repo = data.join("content.git"); + if !repo.exists() { + tokio::fs::create_dir(&repo).await?; + git( + &repo, + &["init", "--bare", "--initial-branch=main", "."], + None, + ) + .await?; + git(&repo, &["read-tree", "--empty"], None).await?; + let tree = git(&repo, &["write-tree"], None).await?; + let tree = String::from_utf8(tree)?; + let commit = git( + &repo, + &[ + "commit-tree", + tree.trim(), + "-m", + "Initialize content repository", + ], + None, + ) + .await?; + let commit = String::from_utf8(commit)?; + git( + &repo, + &["update-ref", "refs/heads/main", commit.trim()], + None, + ) + .await?; + } + Ok(repo) +} + +pub async fn scan(repo: &Path) -> Result { + let commit = String::from_utf8(git(repo, &["rev-parse", "refs/heads/main"], None).await?)? + .trim() + .to_owned(); + scan_at(repo, &commit).await +} + +pub async fn scan_at(repo: &Path, commit: &str) -> Result { + let commit = commit.to_owned(); + let entries = git(repo, &["ls-tree", "-rz", &commit], None).await?; + let mut grouped: BTreeMap, Vec)> = BTreeMap::new(); + let mut total = 0; + for entry in entries.split(|b| *b == 0).filter(|e| !e.is_empty()) { + let entry = std::str::from_utf8(entry)?; + let (info, path) = entry.split_once('\t').context("Git 树格式错误")?; + let info: Vec<_> = info.split_whitespace().collect(); + ensure!( + info.len() == 3 && info[0] == "100644" && info[1] == "blob", + "禁止符号链接及特殊文件" + ); + let parts: Vec<_> = path.split('/').collect(); + ensure!( + parts.len() == 3 && parts[0] == "posts", + "仓库包含非文章路径" + ); + slug(parts[1])?; + let size = String::from_utf8(git(repo, &["cat-file", "-s", info[2]], None).await?)? + .trim() + .parse::()?; + let limit = if parts[2] == "index.md" { + MAX_MARKDOWN + } else { + 2 * 1024 * 1024 + }; + ensure!(size <= limit, "文件超过大小限制"); + let bytes = git(repo, &["cat-file", "blob", info[2]], None).await?; + total += bytes.len(); + ensure!(total <= MAX_SNAPSHOT, "内容仓库超过 64 MiB 配额"); + let group = grouped.entry(parts[1].into()).or_default(); + if parts[2] == "index.md" { + group.0 = Some(String::from_utf8(bytes)?); + } else { + group.1.push(Asset { + name: parts[2].into(), + data: STANDARD.encode(bytes), + }); + } + ensure!(grouped.len() <= 1000, "最多 1000 篇文章"); + } + let mut posts = BTreeMap::new(); + for (key, (markdown, assets)) in grouped { + let post = parse(markdown.context("文章缺少 index.md")?, assets)?; + ensure!(post.meta.slug == key, "slug 与 Git 路径不一致"); + posts.insert(key, post); + } + Ok(Snapshot { commit, posts }) +} + +pub async fn persist(data: &Path, snapshot: &Snapshot) -> Result<()> { + let public = Snapshot { + commit: snapshot.commit.clone(), + posts: snapshot + .posts + .iter() + .filter(|(_, p)| p.meta.status == Status::Published) + .map(|(k, p)| (k.clone(), p.clone())) + .collect(), + }; + let bytes = serde_json::to_vec(&public)?; + let tmp = data.join("published.json.tmp"); + let mut file = tokio::fs::File::create(&tmp).await?; + file.write_all(&bytes).await?; + file.sync_all().await?; + tokio::fs::rename(tmp, data.join("published.json")).await?; + Ok(()) +} + +pub fn validate_markdown(body: &str, assets: &[Asset]) -> Result<()> { + use pulldown_cmark::{Event, Parser, Tag}; + ensure!( + !body.chars().any(|c| c == '\0' + || (c.is_control() && !"\n\r\t".contains(c)) + || matches!(c, '\u{202a}'..='\u{202e}' | '\u{2066}'..='\u{2069}')), + "禁止控制符及双向隐藏字符" + ); + for event in Parser::new_ext(body, pulldown_cmark::Options::all()) { + match event { + Event::Html(_) | Event::InlineHtml(_) => anyhow::bail!("Markdown 不允许原始 HTML"), + Event::Start(Tag::Link { dest_url, .. }) => { + ensure!( + dest_url.starts_with("https://") + || dest_url.starts_with("http://") + || dest_url.starts_with("mailto:") + || dest_url.starts_with('#') + || (dest_url.starts_with('/') && !dest_url.starts_with("//")), + "链接协议不允许" + ); + ensure!( + !dest_url.contains('\\') && !dest_url.chars().any(char::is_control), + "链接含非法字符" + ); + } + Event::Start(Tag::Image { dest_url, .. }) => ensure!( + assets.iter().any(|a| a.name == dest_url.as_ref()), + "图片必须引用同文章已验证的图片文件" + ), + _ => {} + } + } + Ok(()) +} diff --git a/apps/api/src/index.rs b/apps/api/src/index.rs new file mode 100644 index 0000000..77c74fc --- /dev/null +++ b/apps/api/src/index.rs @@ -0,0 +1,145 @@ +use crate::content::{Snapshot, Status}; +use anyhow::{Context, Result, ensure}; +use chusql_driver::{Client, Options, Query}; +use std::time::Duration; + +async fn connect() -> Result { + let database = std::env::var("CHUSQL_DATABASE").context("未配置 ChuSQL")?; + ensure!( + !database.is_empty() + && !database.starts_with(|c: char| c.is_ascii_digit()) + && database + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b == b'_'), + "数据库名称无效" + ); + let db = Client::connect_with_options( + std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into()), + &std::env::var("CHUSQL_USER")?, + &std::env::var("CHUSQL_PASSWORD")?, + Options { + timeout: Duration::from_secs(3), + max_response_bytes: 1024 * 1024, + }, + ) + .await?; + db.query(&format!("USE {database}")).await?; + Ok(db) +} + +pub async fn rebuild(snapshot: &Snapshot) -> Result<()> { + let db = connect().await?; + // Probe before creating: ChuSQL does not promise IF NOT EXISTS syntax. + if db + .query("SELECT slug FROM blog_posts LIMIT 1") + .await + .is_err() + { + db.query("CREATE TABLE blog_posts (slug TEXT PRIMARY KEY, title TEXT, description TEXT, published_at TEXT, updated_at TEXT, path TEXT, git_commit TEXT)").await?; + } + if db + .query("SELECT slug FROM blog_tags LIMIT 1") + .await + .is_err() + { + db.query("CREATE TABLE blog_tags (slug TEXT, tag TEXT, git_commit TEXT)") + .await?; + } + db.query("DELETE FROM blog_tags").await?; + db.query("DELETE FROM blog_posts").await?; + for post in snapshot + .posts + .values() + .filter(|p| p.meta.status == Status::Published) + { + let meta = &post.meta; + let q = Query::new("INSERT INTO blog_posts (slug, title, description, published_at, updated_at, path, git_commit) VALUES (") + .bind(meta.slug.as_str()).push(", ").bind(meta.title.as_str()).push(", ") + .bind(meta.description.as_str()).push(", ").bind(meta.date.as_str()).push(", ") + .bind(meta.updated.as_str()).push(", ").bind(format!("posts/{}/index.md", meta.slug).as_str()) + .push(", ").bind(snapshot.commit.as_str()).push(")"); + db.query(q.as_str()).await?; + for tag in &meta.tags { + let q = Query::new("INSERT INTO blog_tags (slug, tag, git_commit) VALUES (") + .bind(meta.slug.as_str()) + .push(", ") + .bind(tag.as_str()) + .push(", ") + .bind(snapshot.commit.as_str()) + .push(")"); + db.query(q.as_str()).await?; + } + } + db.close().await; + Ok(()) +} + +pub async fn provision() -> Result<()> { + let db = Client::connect( + std::env::var("CHUSQL_ADDRESS").unwrap_or_else(|_| "127.0.0.1:7777".into()), + "root", + &std::env::var("CHUSQL_ROOT_PASSWORD")?, + ) + .await?; + if db.query("USE chublog").await.is_err() { + db.query("CREATE DATABASE chublog").await?; + } + let roles = db.query("SHOW ROLES").await?; + ensure!(!roles.truncated, "身份列表被截断,请人工检查数据库初始化"); + if !roles + .rows + .iter() + .flatten() + .any(|v| v.as_str() == Some("chublog_index")) + { + let password = std::env::var("CHUSQL_PASSWORD")?; + ensure!(password.len() >= 16, "数据库口令至少 16 字节"); + let q = Query::new("CREATE USER chublog_index IDENTIFIED BY ").bind(password.as_str()); + db.query(q.as_str()) + .await + .map_err(|_| anyhow::anyhow!("创建索引用户失败"))?; + } + db.query("REVOKE CONNECT ON DATABASE chublog FROM PUBLIC") + .await?; + db.query("GRANT CONNECT, CREATE ON DATABASE chublog TO chublog_index") + .await?; + db.close().await; + Ok(()) +} + +// Detect a lost/recreated index even if no new article was pushed. +pub async fn consistent(snapshot: &Snapshot) -> Result { + let db = connect().await?; + let result = db + .query("SELECT slug, git_commit FROM blog_posts LIMIT 1000") + .await?; + let expected: std::collections::BTreeSet<_> = snapshot + .posts + .values() + .filter(|p| p.meta.status == Status::Published) + .map(|p| (p.meta.slug.as_str(), snapshot.commit.as_str())) + .collect(); + let actual: std::collections::BTreeSet<_> = result + .rows + .iter() + .filter_map(|row| Some((row.first()?.as_str()?, row.get(1)?.as_str()?))) + .collect(); + let count = db.query("SELECT count(*) FROM blog_tags").await?; + let tags = snapshot + .posts + .values() + .filter(|p| p.meta.status == Status::Published) + .map(|p| p.meta.tags.len()) + .sum::() as u64; + let matches = !result.truncated + && result.rows.len() == expected.len() + && actual == expected + && count + .rows + .first() + .and_then(|row| row.first()) + .and_then(|v| v.as_u64()) + == Some(tags); + db.close().await; + Ok(matches) +} diff --git a/apps/api/src/main.rs b/apps/api/src/main.rs new file mode 100644 index 0000000..e24acfe --- /dev/null +++ b/apps/api/src/main.rs @@ -0,0 +1,330 @@ +mod accounts; +mod content; +mod index; +mod ssh; + +use anyhow::{Context, Result, ensure}; +use axum::{ + Json, Router, + body::Body, + extract::{DefaultBodyLimit, Path, State}, + http::{HeaderMap, HeaderValue, StatusCode, header}, + response::{IntoResponse, Response}, + routing::{get, patch, post, put}, +}; +use base64::Engine; +use content::{Snapshot, Status}; +use serde_json::{Value, json}; +use std::{ + path::PathBuf, + sync::{ + Arc, + atomic::{AtomicU64, Ordering}, + }, + time::Duration, +}; +use tokio::sync::{Mutex, RwLock, Semaphore}; +use tracing::{info, warn}; + +type ApiResult = std::result::Result; +struct ApiError(StatusCode, String); +impl IntoResponse for ApiError { + fn into_response(self) -> Response { + (self.0, Json(json!({"error": self.1}))).into_response() + } +} +fn bad(error: impl std::fmt::Display) -> ApiError { + ApiError(StatusCode::BAD_REQUEST, error.to_string()) +} +fn internal(error: impl std::fmt::Display) -> ApiError { + tracing::error!(error = %error, "operation failed"); + ApiError( + StatusCode::INTERNAL_SERVER_ERROR, + "服务器操作失败,请联系管理员".into(), + ) +} +struct App { + data: PathBuf, + ssh_config: PathBuf, + origin: String, + secure: bool, + registration: bool, + accounts: Mutex, + hash_slots: Arc, + snapshot: RwLock, + indexed: RwLock>, + views: AtomicU64, +} +fn origin(app: &App, headers: &HeaderMap) -> ApiResult<()> { + if headers.get("origin").and_then(|h| h.to_str().ok()) != Some(app.origin.as_str()) { + return Err(ApiError(StatusCode::FORBIDDEN, "Origin 校验失败".into())); + } + Ok(()) +} +async fn posts(State(app): State>) -> Json { + let snapshot = app.snapshot.read().await; + let mut posts: Vec<_> = snapshot + .posts + .values() + .filter(|p| p.meta.status == Status::Published) + .map(|p| &p.meta) + .collect(); + posts.sort_by(|a, b| b.date.cmp(&a.date).then(a.slug.cmp(&b.slug))); + Json(json!({"commit": snapshot.commit, "posts": posts})) +} +async fn public_post( + State(app): State>, + Path(key): Path, +) -> ApiResult> { + let snapshot = app.snapshot.read().await; + let post = snapshot + .posts + .get(&key) + .filter(|p| p.meta.status == Status::Published) + .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "文章不存在".into()))?; + // Relaxed is sufficient: this independent counter does not synchronize other state. + app.views.fetch_add(1, Ordering::Relaxed); + Ok(Json( + json!({"meta": post.meta, "markdown": post.markdown, "assets": post.assets.iter().map(|a| json!({"name": a.name, "url": format!("/api/assets/{key}/{}", a.name)})).collect::>()}), + )) +} +async fn asset( + State(app): State>, + Path((key, name)): Path<(String, String)>, +) -> ApiResult { + let snapshot = app.snapshot.read().await; + let asset = snapshot + .posts + .get(&key) + .filter(|p| p.meta.status == Status::Published) + .and_then(|p| p.assets.iter().find(|a| a.name == name)) + .ok_or_else(|| ApiError(StatusCode::NOT_FOUND, "资源不存在".into()))?; + let bytes = base64::engine::general_purpose::STANDARD + .decode(&asset.data) + .map_err(internal)?; + let mime = match name + .rsplit('.') + .next() + .unwrap_or("") + .to_ascii_lowercase() + .as_str() + { + "png" => "image/png", + "jpg" | "jpeg" => "image/jpeg", + "webp" => "image/webp", + _ => return Err(internal("invalid stored image")), + }; + Ok(([(header::CONTENT_TYPE, mime)], Body::from(bytes)).into_response()) +} +async fn stats(State(app): State>) -> Json { + let snapshot = app.snapshot.read().await; + let posts: Vec<_> = snapshot + .posts + .values() + .filter(|p| p.meta.status == Status::Published) + .collect(); + let tags: std::collections::BTreeSet<_> = posts.iter().flat_map(|p| &p.meta.tags).collect(); + Json( + json!({"published_posts": posts.len(), "tags": tags.len(), "markdown_bytes": posts.iter().map(|p| p.markdown.len()).sum::(), "article_requests_since_start": app.views.load(Ordering::Relaxed)}), + ) +} +async fn admin_status(State(app): State>, headers: HeaderMap) -> ApiResult> { + accounts::authorize(&app, &headers, false, true).await?; + let snapshot = app.snapshot.read().await; + Ok(Json( + json!({"commit": snapshot.commit, "total_posts": snapshot.posts.len(), "drafts": snapshot.posts.values().filter(|p| p.meta.status == Status::Draft).count(), "indexed_commit": *app.indexed.read().await}), + )) +} +async fn sync_index(app: &App, snapshot: &Snapshot) { + match tokio::time::timeout(Duration::from_secs(30), index::rebuild(snapshot)).await { + Ok(Ok(())) => { + *app.indexed.write().await = Some(snapshot.commit.clone()); + info!(action = "index_synced", commit = %snapshot.commit); + } + Ok(Err(error)) => warn!(action = "index_pending", error = %error), + Err(_) => warn!(action = "index_pending", "rebuild timeout"), + } +} +fn router(app: Arc) -> Router { + Router::new() + .route( + "/api/health", + get(|| async { Json(json!({"status": "ok"})) }), + ) + .route("/api/posts", get(posts)) + .route("/api/posts/{slug}", get(public_post)) + .route("/api/assets/{slug}/{name}", get(asset)) + .route("/api/stats", get(stats)) + .route("/api/auth/register", post(accounts::register)) + .route("/api/auth/login", post(accounts::login)) + .route("/api/auth/logout", post(accounts::logout)) + .route("/api/account", get(accounts::me)) + .route("/api/account/ssh-key", put(accounts::set_key)) + .route("/api/account/password", put(accounts::change_password)) + .route("/api/admin/users", get(accounts::users)) + .route("/api/admin/users/{username}", patch(accounts::grant)) + .route("/api/admin/status", get(admin_status)) + .layer(DefaultBodyLimit::max(8 * 1024)) + .layer(axum::middleware::from_fn( + |request: axum::extract::Request, next: axum::middleware::Next| async move { + let mut response = next.run(request).await; + for (key, value) in [ + (header::CACHE_CONTROL, "no-store"), + (header::X_CONTENT_TYPE_OPTIONS, "nosniff"), + ( + header::CONTENT_SECURITY_POLICY, + "default-src 'none'; frame-ancestors 'none'", + ), + (header::REFERRER_POLICY, "no-referrer"), + ] { + response + .headers_mut() + .insert(key, HeaderValue::from_static(value)); + } + response + }, + )) + .with_state(app) +} +async fn refresh(app: &App, repo: &std::path::Path, source: &std::path::Path) -> Result<()> { + // Recover a previously committed ref even if snapshot publication was interrupted. + let mut current = app.snapshot.read().await.clone(); + let head = + String::from_utf8(content::git(repo, &["rev-parse", "refs/heads/main"], None).await?)?; + if current.commit != head.trim() { + current = content::scan(repo).await?; + } + let snapshot = ssh::import(repo, source, ¤t).await?; + if snapshot.commit != app.snapshot.read().await.commit { + content::persist(&app.data, &snapshot).await?; + info!(action = "published", commit = %snapshot.commit); + *app.snapshot.write().await = snapshot; + } + Ok(()) +} +#[tokio::main] +async fn main() -> Result<()> { + tracing_subscriber::fmt() + .with_env_filter(tracing_subscriber::EnvFilter::from_default_env()) + .init(); + let command = std::env::args().nth(1); + match command.as_deref() { + Some("ssh-gate") => { + return ssh::gate(&std::env::args().nth(2).context("缺少发布者身份")?).await; + } + Some("pre-receive") => return ssh::pre_receive().await, + Some("authorized-keys") => return ssh::authorized_keys(), + Some("provision-db") => return index::provision().await, + Some("hash-password") => { + use std::io::Read; + let mut value = String::new(); + std::io::stdin().take(1025).read_to_string(&mut value)?; + println!( + "{}", + accounts::hash_password(value.trim_end_matches(['\r', '\n']).into()).await? + ); + return Ok(()); + } + None | Some("rebuild-index") => {} + Some(_) => anyhow::bail!("未知命令"), + } + let data = PathBuf::from(std::env::var("DATA_DIR").unwrap_or_else(|_| "data".into())); + tokio::fs::create_dir_all(&data).await?; + let data = tokio::fs::canonicalize(data).await?; + let lock_file = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(data.join("writer.lock"))?; + fs2::FileExt::try_lock_exclusive(&lock_file).context("内容目录已被另一个 API 进程使用")?; + let repo = content::init(&data).await?; + let snapshot = content::scan(&repo).await?; + content::persist(&data, &snapshot).await?; + if command.as_deref() == Some("rebuild-index") { + index::rebuild(&snapshot).await?; + return Ok(()); + } + let origin = std::env::var("PUBLIC_ORIGIN").context("必须设置 PUBLIC_ORIGIN")?; + let parsed: axum::http::Uri = origin.parse().context("PUBLIC_ORIGIN 无效")?; + let secure = parsed.scheme_str() == Some("https"); + ensure!( + secure + || (parsed.scheme_str() == Some("http") + && matches!(parsed.host(), Some("localhost" | "127.0.0.1"))), + "生产环境必须使用 HTTPS" + ); + ensure!( + !origin.ends_with('/') + && parsed.path() == "/" + && parsed.query().is_none() + && parsed + .authority() + .is_some_and(|a| !a.as_str().contains('@')), + "PUBLIC_ORIGIN 必须为不带末尾斜杠的站点 origin" + ); + let source = PathBuf::from( + std::env::var("INGRESS_REPO").unwrap_or_else(|_| "/srv/ingress/content.git".into()), + ); + ensure!(source.is_absolute(), "INGRESS_REPO 必须是绝对路径"); + let ssh_config = PathBuf::from( + std::env::var("CHUBLOG_SSH_CONFIG") + .unwrap_or_else(|_| data.join("publishers.json").to_string_lossy().into_owned()), + ); + let accounts = accounts::load(&data, &ssh_config, source.clone()).await?; + ensure!( + accounts.grants.repository == source, + "授权配置仓库与 INGRESS_REPO 不一致" + ); + let app = Arc::new(App { + data, + ssh_config, + origin, + secure, + registration: std::env::var("ALLOW_REGISTRATION").as_deref() == Ok("true"), + accounts: Mutex::new(accounts), + hash_slots: Arc::new(Semaphore::new(2)), + snapshot: RwLock::new(snapshot), + indexed: RwLock::new(None), + views: AtomicU64::new(0), + }); + let worker_app = app.clone(); + let worker = tokio::spawn(async move { + let mut ticks = 0; + loop { + if let Err(error) = refresh(&worker_app, &repo, &source).await { + warn!(action = "import_pending", error = %error); + } + if ticks % 30 == 0 { + let snapshot = worker_app.snapshot.read().await.clone(); + if worker_app.indexed.read().await.as_deref() != Some(snapshot.commit.as_str()) { + sync_index(&worker_app, &snapshot).await; + } else if !matches!( + tokio::time::timeout(Duration::from_secs(10), index::consistent(&snapshot)) + .await, + Ok(Ok(true)) + ) { + *worker_app.indexed.write().await = None; + sync_index(&worker_app, &snapshot).await; + } + } + ticks += 1; + tokio::time::sleep(Duration::from_secs(2)).await; + } + }); + let bind = std::env::var("API_BIND").unwrap_or_else(|_| "127.0.0.1:3001".into()); + let listener = tokio::net::TcpListener::bind(&bind).await?; + info!(%bind, "ChuBlog API ready"); + let mut terminate = tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())?; + axum::serve(listener, router(app)) + .with_graceful_shutdown(async move { + tokio::select! { _ = tokio::signal::ctrl_c() => {}, _ = terminate.recv() => {} } + }) + .await?; + worker.abort(); + let _ = worker.await; + drop(lock_file); + Ok(()) +} + +#[cfg(test)] +mod tests; diff --git a/apps/api/src/ssh.rs b/apps/api/src/ssh.rs new file mode 100644 index 0000000..0b1dc49 --- /dev/null +++ b/apps/api/src/ssh.rs @@ -0,0 +1,274 @@ +use crate::accounts::{Grant, Role}; +use crate::content::{self, Snapshot}; +use anyhow::{Context, Result, bail, ensure}; +use serde::{Deserialize, Serialize}; +use std::{ + collections::BTreeMap, + os::unix::fs::MetadataExt, + path::{Path, PathBuf}, + process::Stdio, + time::Duration, +}; +use tokio::{io::AsyncReadExt, process::Command}; + +#[derive(Clone, Deserialize, Serialize)] +#[serde(deny_unknown_fields)] +pub struct Config { + pub repository: PathBuf, + pub publishers: BTreeMap, +} + +pub fn config(path: &Path) -> Result { + let meta = std::fs::symlink_metadata(path)?; + ensure!( + meta.is_file() && (meta.uid() == 0 || meta.uid() == 10001) && meta.mode() & 0o022 == 0, + "SSH 授权配置必须为 root 或 API UID 10001 所有且不可被组或其他用户写入" + ); + let bytes = std::fs::read(path)?; + ensure!(bytes.len() < 65536, "SSH 配置过大"); + let cfg: Config = serde_json::from_slice(&bytes)?; + ensure!(cfg.repository.is_absolute(), "接收仓库必须为绝对路径"); + Ok(cfg) +} +fn authorized<'a>(cfg: &'a Config, identity: &str) -> Result<&'a Grant> { + ensure!( + !identity.is_empty() + && identity.len() <= 64 + && identity + .bytes() + .all(|b| b.is_ascii_alphanumeric() || b"_-".contains(&b)), + "发布者身份无效" + ); + let publisher = cfg + .publishers + .get(identity) + .filter(|p| p.enabled && p.role != Role::Reader && p.ssh_key.is_some()) + .context("服务器未授权此身份发布文章")?; + ensure!( + !publisher.prefixes.is_empty() + && publisher.prefixes.iter().all(|p| !p.is_empty() + && p.len() <= 64 + && p.bytes() + .all(|b| b.is_ascii_lowercase() || b.is_ascii_digit() || b == b'-')), + "发布者必须拥有明确 slug 前缀" + ); + Ok(publisher) +} +fn directory_size(path: &Path) -> Result { + let mut total = 0u64; + for entry in std::fs::read_dir(path)? { + let entry = entry?; + let meta = entry.file_type()?; + ensure!(!meta.is_symlink(), "接收仓库不得包含符号链接"); + total = total.saturating_add(if meta.is_dir() { + directory_size(&entry.path())? + } else { + entry.metadata()?.len() + }); + ensure!( + total <= 256 * 1024 * 1024, + "接收仓库达到 256 MiB 配额,请管理员维护" + ); + } + Ok(total) +} + +pub async fn gate(identity: &str) -> Result<()> { + let config_path = PathBuf::from( + std::env::var("CHUBLOG_SSH_CONFIG").unwrap_or_else(|_| "/auth/publishers.json".into()), + ); + let cfg = config(&config_path)?; + authorized(&cfg, identity)?; + let original = std::env::var("SSH_ORIGINAL_COMMAND").unwrap_or_default(); + ensure!( + matches!( + original.as_str(), + "git-receive-pack 'content.git'" | "git-upload-pack 'content.git'" + ), + "仅允许对 content.git 执行 Git 推送或读取" + ); + let receiving = original.starts_with("git-receive-pack"); + directory_size(&cfg.repository)?; + let lock = std::fs::OpenOptions::new() + .create(true) + .truncate(false) + .write(true) + .open(cfg.repository.join("ssh.lock"))?; + fs2::FileExt::try_lock_exclusive(&lock).context("另一位发布者正在访问仓库,请稍后重试")?; + let executable = if receiving { + "git-receive-pack" + } else { + "git-upload-pack" + }; + let mut child = Command::new(executable) + .arg(&cfg.repository) + .env_clear() + .env("PATH", "/usr/bin:/bin") + .env("HOME", "/nonexistent") + .env("GIT_CONFIG_NOSYSTEM", "1") + .env("GIT_CONFIG_GLOBAL", "/dev/null") + .env("CHUBLOG_SSH_CONFIG", &config_path) + .env("CHUBLOG_PUBLISHER", identity) + .env("GIT_CONFIG_COUNT", "5") + .env("GIT_CONFIG_KEY_0", "receive.maxInputSize") + .env("GIT_CONFIG_VALUE_0", "8388608") + .env("GIT_CONFIG_KEY_1", "receive.fsckObjects") + .env("GIT_CONFIG_VALUE_1", "true") + .env("GIT_CONFIG_KEY_2", "transfer.fsckObjects") + .env("GIT_CONFIG_VALUE_2", "true") + .env("GIT_CONFIG_KEY_3", "gc.auto") + .env("GIT_CONFIG_VALUE_3", "0") + .env("GIT_CONFIG_KEY_4", "core.hooksPath") + .env("GIT_CONFIG_VALUE_4", "/hooks") + .stdin(Stdio::inherit()) + .stdout(Stdio::inherit()) + .stderr(Stdio::inherit()) + .kill_on_drop(true) + .spawn() + .context("启动受限 Git 服务失败")?; + let status = match tokio::time::timeout(Duration::from_secs(90), child.wait()).await { + Ok(status) => status?, + Err(_) => { + child.kill().await?; + bail!("SSH Git 会话超过 90 秒限制"); + } + }; + ensure!(status.success(), "Git 访问失败"); + drop(lock); + Ok(()) +} + +pub async fn validate_change( + repo: &Path, + previous: &str, + next: &str, + publisher: &Grant, +) -> Result { + ensure!( + next.len() == 40 + && next.bytes().all(|b| b.is_ascii_hexdigit()) + && next != "0000000000000000000000000000000000000000", + "禁止删除主分支或无效提交" + ); + let initial = previous == "0000000000000000000000000000000000000000"; + let parents = + String::from_utf8(content::git(repo, &["show", "-s", "--format=%P", next], None).await?)?; + ensure!( + parents.trim() == if initial { "" } else { previous }, + "每次只允许推送一个直接继承主分支的提交,首次必须为根提交;不允许合并或强推" + ); + let snapshot = content::scan_at(repo, next).await?; + // Authorize raw Git paths: normalization must not hide edits to another author's bytes. + let changes = if initial { + content::git(repo, &["ls-tree", "--name-only", "-rz", next], None).await? + } else { + content::git( + repo, + &[ + "diff-tree", + "--no-commit-id", + "--name-only", + "--no-renames", + "-r", + "-z", + previous, + next, + ], + None, + ) + .await? + }; + for path in changes.split(|b| *b == 0).filter(|p| !p.is_empty()) { + let path = std::str::from_utf8(path)?; + let parts: Vec<_> = path.split('/').collect(); + ensure!(parts.len() == 3 && parts[0] == "posts", "修改路径无效"); + let key = content::slug(parts[1])?; + ensure!( + publisher.prefixes.iter().any(|p| key.starts_with(p)), + "无权修改文章 {key}" + ); + } + Ok(snapshot) +} + +pub async fn pre_receive() -> Result<()> { + let config_path = + PathBuf::from(std::env::var("CHUBLOG_SSH_CONFIG").context("缺少受控 SSH 配置")?); + let cfg = config(&config_path)?; + let identity = std::env::var("CHUBLOG_PUBLISHER").context("拒绝未经 SSH 网关的推送")?; + let publisher = authorized(&cfg, &identity)?; + let mut input = String::new(); + tokio::io::stdin() + .take(1025) + .read_to_string(&mut input) + .await?; + ensure!(input.len() <= 1024, "引用更新过多"); + let lines: Vec<_> = input.lines().collect(); + ensure!(lines.len() == 1, "每次只能更新主分支,禁止标签及其他引用"); + let fields: Vec<_> = lines[0].split_whitespace().collect(); + ensure!( + fields.len() == 3 && fields[2] == "refs/heads/main", + "仅允许 refs/heads/main" + ); + ensure!( + fields[0].len() == 40 && fields[0].bytes().all(|b| b.is_ascii_hexdigit()), + "旧提交无效" + ); + validate_change(&cfg.repository, fields[0], fields[1], publisher).await?; + let latest = config(&config_path)?; + let current = authorized(&latest, &identity)?; + ensure!( + current.prefixes == publisher.prefixes && current.ssh_key == publisher.ssh_key, + "推送期间授权已变更,请重新连接" + ); + eprintln!( + "ChuBlog: identity={identity} validated commit={}", + fields[1] + ); + Ok(()) +} + +// Fetch into API-owned storage: the SSH account cannot alter published refs or snapshots. +pub async fn import(repo: &Path, source: &Path, current: &Snapshot) -> Result { + content::git( + repo, + &[ + "fetch", + "--no-tags", + "--no-recurse-submodules", + "--", + source.to_str().context("接收路径无效")?, + "refs/heads/main", + ], + None, + ) + .await?; + let commit = String::from_utf8(content::git(repo, &["rev-parse", "FETCH_HEAD"], None).await?)?; + let commit = commit.trim(); + if commit == current.commit { + return Ok(current.clone()); + } + let snapshot = content::scan_at(repo, commit).await?; + content::git( + repo, + &["update-ref", "refs/heads/main", commit, ¤t.commit], + None, + ) + .await?; + Ok(snapshot) +} + +pub fn authorized_keys() -> Result<()> { + let path = + std::env::var("CHUBLOG_SSH_CONFIG").unwrap_or_else(|_| "/auth/publishers.json".into()); + let cfg = config(Path::new(&path))?; + for (identity, grant) in &cfg.publishers { + if authorized(&cfg, identity).is_ok() + && let Some(key) = &grant.ssh_key + { + let key = crate::accounts::public_key(key)?; + println!("restrict,command=\"/usr/local/bin/chublog-api ssh-gate {identity}\" {key}"); + } + } + Ok(()) +} diff --git a/apps/api/src/tests.rs b/apps/api/src/tests.rs new file mode 100644 index 0000000..3c2c1fa --- /dev/null +++ b/apps/api/src/tests.rs @@ -0,0 +1,614 @@ +use super::*; +use accounts::{Grant, Role}; +use axum::http::Request; +use std::collections::BTreeMap; +use tower::ServiceExt; + +fn markdown(key: &str, status: &str, body: &str) -> String { + format!( + "---\ntitle: Test\nslug: {key}\ndate: '2026-10-15'\nupdated: '2026-10-15'\nstatus: {status}\ntags: [Rust]\ndescription: Test article\n---\n\n{body}\n" + ) +} +fn grant(prefix: &str) -> Grant { + Grant { + enabled: true, + role: Role::Publisher, + prefixes: vec![prefix.into()], + ssh_key: None, + } +} +async fn commit( + repo: &std::path::Path, + parent: Option<&str>, + files: &[(&str, &str, &str)], +) -> String { + content::git(repo, &["read-tree", "--empty"], None) + .await + .unwrap(); + for (path, mode, body) in files { + let object = String::from_utf8( + content::git( + repo, + &["hash-object", "-w", "--stdin"], + Some(body.as_bytes()), + ) + .await + .unwrap(), + ) + .unwrap(); + content::git( + repo, + &[ + "update-index", + "--add", + "--cacheinfo", + mode, + object.trim(), + path, + ], + None, + ) + .await + .unwrap(); + } + let tree = String::from_utf8(content::git(repo, &["write-tree"], None).await.unwrap()).unwrap(); + let mut args = vec!["commit-tree", tree.trim(), "-m", "Test"]; + if let Some(parent) = parent { + args.extend(["-p", parent]); + } + String::from_utf8(content::git(repo, &args, None).await.unwrap()) + .unwrap() + .trim() + .into() +} +#[test] +fn rejects_active_content_and_unsafe_metadata_but_keeps_inert_code() { + for body in [ + "", + "", + "[x](javascript:alert%281%29)", + "[x](javascript:alert%281%29)", + "![x](https://evil.test/x.png)", + "![x](../secret.png)", + "a\u{202e}exe", + "[x](//evil.test)", + "[x](file:///etc/passwd)", + ] { + assert!( + content::parse(markdown("alice-test", "published", body), vec![]).is_err(), + "accepted {body}" + ); + } + assert!(content::parse(markdown("../escape", "published", "Hello"), vec![]).is_err()); + assert!( + content::parse( + markdown( + "alice-test", + "published", + &"x".repeat(content::MAX_MARKDOWN) + ), + vec![] + ) + .is_err() + ); + assert!( + content::parse( + markdown( + "alice-test", + "published", + "```html\n\n```\n[valid](https://example.com)" + ), + vec![] + ) + .is_ok() + ); + assert!( + content::decode_asset(&content::Asset { + name: "hidden.svg".into(), + data: "PHN2Zz4=".into() + }) + .is_err() + ); + assert!( + content::decode_asset(&content::Asset { + name: "fake.png".into(), + data: "PHNjcmlwdD4=".into() + }) + .is_err() + ); +} +#[tokio::test] +async fn git_checks_namespace_history_and_file_modes_before_publication() { + let dir = tempfile::tempdir().unwrap(); + let repo = content::init(dir.path()).await.unwrap(); + let zero = "0000000000000000000000000000000000000000"; + let first = commit( + &repo, + None, + &[( + "posts/alice-first/index.md", + "100644", + &markdown("alice-first", "published", "Hello"), + )], + ) + .await; + let snapshot = ssh::validate_change(&repo, zero, &first, &grant("alice-")) + .await + .unwrap(); + assert_eq!(snapshot.posts.len(), 1); + assert!( + ssh::validate_change(&repo, zero, &first, &grant("bob-")) + .await + .is_err() + ); + let crlf = commit( + &repo, + Some(&first), + &[( + "posts/alice-first/index.md", + "100644", + &markdown("alice-first", "published", "Hello").replace('\n', "\r\n"), + )], + ) + .await; + assert!( + ssh::validate_change(&repo, &first, &crlf, &grant("bob-")) + .await + .is_err(), + "normalization must not bypass path authorization" + ); + let second = commit( + &repo, + Some(&first), + &[( + "posts/alice-first/index.md", + "100644", + &markdown("alice-first", "draft", "Private"), + )], + ) + .await; + ssh::validate_change(&repo, &first, &second, &grant("alice-")) + .await + .unwrap(); + let third = commit( + &repo, + Some(&second), + &[( + "posts/alice-first/index.md", + "100644", + &markdown("alice-first", "published", "Skipped commit"), + )], + ) + .await; + assert!( + ssh::validate_change(&repo, &first, &third, &grant("alice-")) + .await + .is_err() + ); + let link = commit( + &repo, + Some(&first), + &[("posts/alice-first/index.md", "120000", "/etc/passwd")], + ) + .await; + assert!( + ssh::validate_change(&repo, &first, &link, &grant("alice-")) + .await + .is_err() + ); + let executable = commit( + &repo, + Some(&first), + &[( + "posts/alice-first/index.md", + "100755", + &markdown("alice-first", "published", "Hello"), + )], + ) + .await; + assert!( + ssh::validate_change(&repo, &first, &executable, &grant("alice-")) + .await + .is_err() + ); + let hidden = commit( + &repo, + Some(&first), + &[(".github/workflows/run.yml", "100644", "bad")], + ) + .await; + assert!( + ssh::validate_change(&repo, &first, &hidden, &grant("alice-")) + .await + .is_err() + ); + assert!( + ssh::validate_change(&repo, &first, zero, &grant("alice-")) + .await + .is_err() + ); + content::persist(dir.path(), &content::scan_at(&repo, &second).await.unwrap()) + .await + .unwrap(); + let public: Snapshot = serde_json::from_slice( + &tokio::fs::read(dir.path().join("published.json")) + .await + .unwrap(), + ) + .unwrap(); + assert!(public.posts.is_empty(), "draft leaked into public snapshot"); +} +#[test] +fn ssh_keys_cannot_inject_options_or_commands() { + let mut bytes = vec![0, 0, 0, 11]; + bytes.extend(b"ssh-ed25519"); + bytes.extend([0, 0, 0, 32]); + bytes.extend([42; 32]); + let key = format!( + "ssh-ed25519 {} comment", + base64::engine::general_purpose::STANDARD.encode(bytes) + ); + assert!(accounts::public_key(&key).is_ok()); + assert!(accounts::public_key(&format!("command=\"sh\" {key}")).is_err()); + assert!(accounts::public_key(&format!("{key}\nssh-rsa AAA")).is_err()); + assert!(accounts::public_key("ssh-ed25519 AAAA").is_err()); +} +async fn app(dir: &std::path::Path) -> Arc { + let config = dir.join("publishers.json"); + let hash = accounts::hash_password("Administrator-test-123".into()) + .await + .unwrap(); + accounts::atomic( + &dir.join("users.json"), + &serde_json::to_vec(&json!({"admin": {"hash": hash}})).unwrap(), + 0o600, + ) + .await + .unwrap(); + let grants = ssh::Config { + repository: dir.join("ingress.git"), + publishers: BTreeMap::from([( + "admin".into(), + Grant { + enabled: true, + role: Role::Admin, + prefixes: vec![], + ssh_key: None, + }, + )]), + }; + accounts::atomic(&config, &serde_json::to_vec(&grants).unwrap(), 0o644) + .await + .unwrap(); + let accounts = accounts::load(dir, &config, grants.repository) + .await + .unwrap(); + let published = content::parse(markdown("alice-public", "published", "Hello"), vec![]).unwrap(); + let draft = content::parse(markdown("alice-secret", "draft", "Secret"), vec![]).unwrap(); + Arc::new(App { + data: dir.into(), + ssh_config: config, + origin: "http://localhost:4321".into(), + secure: false, + registration: true, + accounts: Mutex::new(accounts), + hash_slots: Arc::new(Semaphore::new(2)), + snapshot: RwLock::new(Snapshot { + commit: "test".into(), + posts: BTreeMap::from([ + ("alice-public".into(), published), + ("alice-secret".into(), draft), + ]), + }), + indexed: RwLock::new(None), + views: AtomicU64::new(0), + }) +} +async fn request( + app: Arc, + method: &str, + path: &str, + body: Value, + cookie: Option<&str>, + csrf: Option<&str>, +) -> (StatusCode, HeaderMap, Value) { + let mut builder = Request::builder() + .method(method) + .uri(path) + .header("content-type", "application/json") + .header("origin", "http://localhost:4321"); + if let Some(cookie) = cookie { + builder = builder.header("cookie", cookie); + } + if let Some(csrf) = csrf { + builder = builder.header("x-csrf-token", csrf); + } + let response = router(app) + .oneshot(builder.body(Body::from(body.to_string())).unwrap()) + .await + .unwrap(); + let status = response.status(); + let headers = response.headers().clone(); + let bytes = axum::body::to_bytes(response.into_body(), 1024 * 1024) + .await + .unwrap(); + ( + status, + headers, + serde_json::from_slice(&bytes).unwrap_or(Value::Null), + ) +} +#[tokio::test] +async fn registration_login_grants_revocation_and_private_content_boundaries() { + let dir = tempfile::tempdir().unwrap(); + let app = app(dir.path()).await; + let (status, _, _) = request( + app.clone(), + "POST", + "/api/auth/register", + json!({"username":"alice", "password":"Alice-test-password-123", "role":"admin"}), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::UNPROCESSABLE_ENTITY); + let (status, _, value) = request( + app.clone(), + "POST", + "/api/auth/register", + json!({"username":"alice", "password":"Alice-test-password-123"}), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!(value["role"], "reader"); + let (_, headers, value) = request( + app.clone(), + "POST", + "/api/auth/login", + json!({"username":"alice", "password":"Alice-test-password-123"}), + None, + None, + ) + .await; + let alice_cookie = headers[header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let alice_csrf = value["csrf"].as_str().unwrap().to_owned(); + let (status, _, _) = request( + app.clone(), + "GET", + "/api/admin/users", + Value::Null, + Some(&alice_cookie), + None, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (_, headers, value) = request( + app.clone(), + "POST", + "/api/auth/login", + json!({"username":"admin", "password":"Administrator-test-123"}), + None, + None, + ) + .await; + let admin_cookie = headers[header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let admin_csrf = value["csrf"].as_str().unwrap().to_owned(); + let input = json!({"enabled":true,"role":"publisher","prefixes":["alice-"]}); + let (status, _, _) = request( + app.clone(), + "PATCH", + "/api/admin/users/alice", + input.clone(), + Some(&admin_cookie), + None, + ) + .await; + assert_eq!(status, StatusCode::FORBIDDEN); + let (status, _, _) = request( + app.clone(), + "PATCH", + "/api/admin/users/alice", + input, + Some(&admin_cookie), + Some(&admin_csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + assert_eq!( + app.accounts.lock().await.grants.publishers["alice"].role, + Role::Publisher + ); + let disk = tokio::fs::read(dir.path().join("users.json")) + .await + .unwrap(); + assert!( + !String::from_utf8(disk) + .unwrap() + .contains("Alice-test-password-123") + ); + let (status, _, _) = request( + app.clone(), + "POST", + "/api/auth/logout", + json!({}), + Some(&alice_cookie), + Some(&alice_csrf), + ) + .await; + assert_eq!( + status, + StatusCode::UNAUTHORIZED, + "grant change must invalidate existing sessions" + ); + let (status, _, _) = request( + app.clone(), + "PATCH", + "/api/admin/users/alice", + json!({"enabled":false,"role":"reader","prefixes":[]}), + Some(&admin_cookie), + Some(&admin_csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _, _) = request( + app.clone(), + "POST", + "/api/auth/login", + json!({"username":"alice", "password":"Alice-test-password-123"}), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (_, _, value) = request(app.clone(), "GET", "/api/posts", Value::Null, None, None).await; + assert_eq!(value["posts"].as_array().unwrap().len(), 1); + let (status, _, _) = request( + app.clone(), + "GET", + "/api/posts/alice-secret", + Value::Null, + None, + None, + ) + .await; + assert_eq!(status, StatusCode::NOT_FOUND); + let (_, _, value) = request(app.clone(), "GET", "/api/stats", Value::Null, None, None).await; + assert_eq!(value["published_posts"], 1); + let (status, _, _) = request( + app.clone(), + "POST", + "/api/admin/posts/publish", + json!({}), + Some(&admin_cookie), + Some(&admin_csrf), + ) + .await; + assert_eq!( + status, + StatusCode::NOT_FOUND, + "online publishing must not exist" + ); +} + +#[tokio::test] +async fn password_rotation_origin_and_request_limits() { + let dir = tempfile::tempdir().unwrap(); + let app = app(dir.path()).await; + let response = router(app.clone()) + .oneshot( + Request::builder() + .method("POST") + .uri("/api/auth/login") + .header("content-type", "application/json") + .body(Body::from( + json!({"username":"admin","password":"Administrator-test-123"}).to_string(), + )) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!( + response.status(), + StatusCode::FORBIDDEN, + "login must validate Origin" + ); + let (_, headers, value) = request( + app.clone(), + "POST", + "/api/auth/login", + json!({"username":"admin","password":"Administrator-test-123"}), + None, + None, + ) + .await; + let cookie = headers[header::SET_COOKIE] + .to_str() + .unwrap() + .split(';') + .next() + .unwrap() + .to_owned(); + let csrf = value["csrf"].as_str().unwrap(); + let (status, _, _) = request( + app.clone(), + "PUT", + "/api/account/password", + json!({"old_password":"Incorrect-password", "new_password":"New-password-12345"}), + Some(&cookie), + Some(csrf), + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _, _) = request( + app.clone(), + "PUT", + "/api/account/password", + json!({"old_password":"Administrator-test-123", "new_password":"New-password-12345"}), + Some(&cookie), + Some(csrf), + ) + .await; + assert_eq!(status, StatusCode::OK); + let (status, _, _) = request( + app.clone(), + "GET", + "/api/account", + Value::Null, + Some(&cookie), + None, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let loaded = accounts::load(dir.path(), &app.ssh_config, dir.path().join("ingress.git")) + .await + .unwrap(); + *app.accounts.lock().await = loaded; + let (status, _, _) = request( + app.clone(), + "POST", + "/api/auth/login", + json!({"username":"admin", "password":"Administrator-test-123"}), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::UNAUTHORIZED); + let (status, _, _) = request( + app.clone(), + "POST", + "/api/auth/login", + json!({"username":"admin", "password":"New-password-12345"}), + None, + None, + ) + .await; + assert_eq!( + status, + StatusCode::OK, + "password change must survive account reload" + ); + let (status, _, _) = request( + app, + "POST", + "/api/auth/register", + json!({"username":"large", "password":"x".repeat(9000)}), + None, + None, + ) + .await; + assert_eq!(status, StatusCode::PAYLOAD_TOO_LARGE); +} diff --git a/content/posts/welcome/index.md b/content/posts/welcome/index.md new file mode 100644 index 0000000..b7a6fee --- /dev/null +++ b/content/posts/welcome/index.md @@ -0,0 +1,19 @@ +--- +title: "第一篇文章" +slug: "welcome" +date: "2026-10-15" +updated: "2026-10-15" +status: "published" +tags: ["随笔"] +description: "从本地 Markdown 开始。" +--- + +# 你好,ChuBlog + +在本地编辑这篇文章,通过受限 SSH Git 推送发布。 + +```rust +fn main() { + println!("Hello, ChuBlog!"); +} +``` diff --git a/deploy/.dockerignore b/deploy/.dockerignore new file mode 100644 index 0000000..94d39c8 --- /dev/null +++ b/deploy/.dockerignore @@ -0,0 +1,16 @@ +* +!Dockerfile +!bin/ +!bin/chublog-api +!bin/chusql-server +!bin/csql-bootstrap +!bin/libchusql_core_storage.so +!sshd_config +!gitconfig +!authorized-keys.sh +!ssh-entrypoint.sh +!init-volumes.sh +!db-entrypoint.sh +!chusql.toml +!hooks/ +!hooks/pre-receive diff --git a/deploy/Caddyfile b/deploy/Caddyfile new file mode 100644 index 0000000..6242cc8 --- /dev/null +++ b/deploy/Caddyfile @@ -0,0 +1,25 @@ +{$SITE_ADDRESS:localhost} { + request_body { + max_size 8KB + } + header { + X-Content-Type-Options nosniff + Referrer-Policy no-referrer + -Server + } + handle /api/* { + reverse_proxy api:3001 { + transport http { + read_timeout 15s + write_timeout 15s + } + } + } + handle { + respond "ChuBlog backend is running. Frontend is planned separately." 200 + } + log { + output stdout + format json + } +} diff --git a/deploy/Dockerfile b/deploy/Dockerfile new file mode 100644 index 0000000..98c8fae --- /dev/null +++ b/deploy/Dockerfile @@ -0,0 +1,38 @@ +FROM debian:trixie-slim AS base +ARG DEBIAN_MIRROR=deb.debian.org +RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources +RUN apt-get update && apt-get install -y --no-install-recommends git ca-certificates libgmp10 curl util-linux && rm -rf /var/lib/apt/lists/* +COPY bin/chublog-api /usr/local/bin/chublog-api +COPY gitconfig /etc/chublog/gitconfig +RUN groupadd -g 10002 ingress && useradd -u 10001 -g ingress -M -s /usr/sbin/nologin blog + +FROM base AS api +USER 10001:10002 +ENV API_BIND=0.0.0.0:3001 DATA_DIR=/data CHUBLOG_SSH_CONFIG=/auth/publishers.json INGRESS_REPO=/srv/ingress/content.git RUST_LOG=info +ENTRYPOINT ["chublog-api"] + +FROM base AS ssh +RUN apt-get update && apt-get install -y --no-install-recommends openssh-server && rm -rf /var/lib/apt/lists/* && useradd -u 10002 -g ingress -M -d /srv/ingress -s /bin/sh git && passwd -d git +COPY sshd_config /etc/ssh/sshd_config +COPY hooks /hooks +COPY ssh-entrypoint.sh /usr/local/bin/ssh-entrypoint +COPY authorized-keys.sh /usr/local/bin/chublog-authorized-keys +RUN chmod 755 /hooks/pre-receive /usr/local/bin/ssh-entrypoint /usr/local/bin/chublog-authorized-keys +ENTRYPOINT ["/usr/local/bin/ssh-entrypoint"] + +FROM base AS init +COPY init-volumes.sh /usr/local/bin/init-volumes +RUN chmod 755 /usr/local/bin/init-volumes +ENTRYPOINT ["/usr/local/bin/init-volumes"] + +FROM debian:trixie-slim AS database +ARG DEBIAN_MIRROR=deb.debian.org +RUN sed -i "s|deb.debian.org|${DEBIAN_MIRROR}|g" /etc/apt/sources.list.d/debian.sources +RUN apt-get update && apt-get install -y --no-install-recommends libgmp10 ca-certificates && rm -rf /var/lib/apt/lists/* && useradd -u 10003 -M -s /usr/sbin/nologin chusql +COPY bin/chusql-server bin/csql-bootstrap bin/libchusql_core_storage.so /usr/local/bin/ +COPY chusql.toml /etc/chusql/settings.toml +COPY db-entrypoint.sh /usr/local/bin/db-entrypoint +RUN chmod 755 /usr/local/bin/db-entrypoint +ENV LD_LIBRARY_PATH=/usr/local/bin +USER 10003:10003 +ENTRYPOINT ["/usr/local/bin/db-entrypoint"] diff --git a/deploy/authorized-keys.sh b/deploy/authorized-keys.sh new file mode 100644 index 0000000..1345dd5 --- /dev/null +++ b/deploy/authorized-keys.sh @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec /usr/local/bin/chublog-api authorized-keys diff --git a/deploy/chusql.toml b/deploy/chusql.toml new file mode 100644 index 0000000..7a59f5f --- /dev/null +++ b/deploy/chusql.toml @@ -0,0 +1,9 @@ +[storage] +data_dir = "/var/lib/chusql" +log_files = "/var/log/chusql" +[server] +listen_host = "0.0.0.0" +port = 7777 +max_message = 1048576 +max_rows = 1000 +sudo_auth_user = "" diff --git a/deploy/compose.yaml b/deploy/compose.yaml new file mode 100644 index 0000000..a291718 --- /dev/null +++ b/deploy/compose.yaml @@ -0,0 +1,137 @@ +name: chublog +x-logging: &logging + driver: json-file + options: + max-size: "10m" + max-file: "3" +services: + init: + build: { context: ., target: init, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } } + volumes: + - content:/data + - authorization:/auth + - ingress:/srv/ingress + - database:/db-data + - db-logs:/db-logs + restart: "no" + network_mode: none + logging: *logging + api: + build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } } + depends_on: + init: { condition: service_completed_successfully } + environment: + PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?Set PUBLIC_ORIGIN} + ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} + ADMIN_PASSWORD_HASH: ${ADMIN_PASSWORD_HASH:?Set ADMIN_PASSWORD_HASH} + ALLOW_REGISTRATION: ${ALLOW_REGISTRATION:-false} + CHUSQL_ADDRESS: db:7777 + CHUSQL_USER: chublog_index + CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD} + CHUSQL_DATABASE: chublog + volumes: + - content:/data + - authorization:/auth + - ingress:/srv/ingress:ro + read_only: true + tmpfs: ["/tmp:size=16m,noexec,nosuid"] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 128 + mem_limit: 512m + cpus: 1.0 + networks: [backend] + restart: unless-stopped + healthcheck: + test: [CMD, curl, -fsS, http://127.0.0.1:3001/api/health] + interval: 10s + timeout: 3s + retries: 3 + logging: *logging + ssh: + build: { context: ., target: ssh, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } } + depends_on: + init: { condition: service_completed_successfully } + api: { condition: service_healthy } + environment: + CHUBLOG_SSH_CONFIG: /auth/publishers.json + ports: ["${SSH_BIND:-0.0.0.0}:${SSH_PORT:-2222}:2222"] + volumes: + - ingress:/srv/ingress + - authorization:/auth:ro + - ssh-host-keys:/host-keys + read_only: true + tmpfs: ["/run:size=8m,noexec,nosuid", "/tmp:size=16m,noexec,nosuid"] + cap_drop: [ALL] + cap_add: [SETUID, SETGID, SYS_CHROOT] + security_opt: [no-new-privileges:true] + pids_limit: 64 + mem_limit: 256m + cpus: 1.0 + networks: [ssh] + restart: unless-stopped + logging: *logging + db: + build: { context: ., target: database, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } } + depends_on: + init: { condition: service_completed_successfully } + environment: + CHUSQL_ROOT_PASSWORD: ${CHUSQL_ROOT_PASSWORD:?Set CHUSQL_ROOT_PASSWORD} + volumes: + - database:/var/lib/chusql + - db-logs:/var/log/chusql + read_only: true + tmpfs: ["/tmp:size=16m,noexec,nosuid"] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + pids_limit: 64 + mem_limit: 512m + cpus: 1.0 + networks: [backend] + restart: unless-stopped + logging: *logging + db-init: + build: { context: ., target: api, args: { DEBIAN_MIRROR: "${DEBIAN_MIRROR:-deb.debian.org}" } } + profiles: [setup] + entrypoint: [chublog-api, provision-db] + environment: + CHUSQL_ADDRESS: db:7777 + CHUSQL_ROOT_PASSWORD: ${CHUSQL_ROOT_PASSWORD:?Set CHUSQL_ROOT_PASSWORD} + CHUSQL_PASSWORD: ${CHUSQL_PASSWORD:?Set CHUSQL_PASSWORD} + depends_on: [db] + networks: [backend] + cap_drop: [ALL] + security_opt: [no-new-privileges:true] + read_only: true + restart: "no" + logging: *logging + caddy: + image: caddy:2 + environment: + SITE_ADDRESS: ${SITE_ADDRESS:-localhost} + ports: ["${HTTP_BIND:-0.0.0.0}:${HTTP_PORT:-80}:80", "${HTTP_BIND:-0.0.0.0}:${HTTPS_PORT:-443}:443"] + volumes: + - ./Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + cap_drop: [ALL] + cap_add: [NET_BIND_SERVICE] + security_opt: [no-new-privileges:true] + networks: [backend, edge] + depends_on: + api: { condition: service_healthy } + restart: unless-stopped + logging: *logging +networks: + backend: { internal: true } + ssh: {} + edge: {} +volumes: + content: {} + ingress: {} + authorization: {} + ssh-host-keys: {} + database: {} + db-logs: {} + caddy-data: {} + caddy-config: {} diff --git a/deploy/db-entrypoint.sh b/deploy/db-entrypoint.sh new file mode 100644 index 0000000..88dd99c --- /dev/null +++ b/deploy/db-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +if [ ! -d /var/lib/chusql/system ]; then + printf '%s\n' "$CHUSQL_ROOT_PASSWORD" | csql-bootstrap --config /etc/chusql/settings.toml --user root --password-stdin +fi +unset CHUSQL_ROOT_PASSWORD +exec chusql-server --config /etc/chusql/settings.toml --listen-host 0.0.0.0 --port 7777 diff --git a/deploy/gitconfig b/deploy/gitconfig new file mode 100644 index 0000000..4c2e9d8 --- /dev/null +++ b/deploy/gitconfig @@ -0,0 +1,2 @@ +[safe] + directory = /srv/ingress/content.git diff --git a/deploy/hooks/pre-receive b/deploy/hooks/pre-receive new file mode 100644 index 0000000..1b68e99 --- /dev/null +++ b/deploy/hooks/pre-receive @@ -0,0 +1,3 @@ +#!/bin/sh +set -eu +exec /usr/local/bin/chublog-api pre-receive diff --git a/deploy/init-volumes.sh b/deploy/init-volumes.sh new file mode 100644 index 0000000..d210c95 --- /dev/null +++ b/deploy/init-volumes.sh @@ -0,0 +1,14 @@ +#!/bin/sh +set -eu +mkdir -p /data /auth /srv/ingress /db-data /db-logs +chown 10001:10002 /data /auth +chmod 700 /data +chmod 755 /auth +chown 10002:10002 /srv/ingress +chmod 750 /srv/ingress +chown 10003:10003 /db-data /db-logs +if [ ! -d /srv/ingress/content.git ]; then + setpriv --reuid=10002 --regid=10002 --clear-groups git init --bare --initial-branch=main --shared=group /srv/ingress/content.git + setpriv --reuid=10002 --regid=10002 --clear-groups git -C /srv/ingress/content.git config receive.denyNonFastForwards true + setpriv --reuid=10002 --regid=10002 --clear-groups git -C /srv/ingress/content.git config receive.denyDeletes true +fi diff --git a/deploy/ssh-entrypoint.sh b/deploy/ssh-entrypoint.sh new file mode 100644 index 0000000..45db471 --- /dev/null +++ b/deploy/ssh-entrypoint.sh @@ -0,0 +1,7 @@ +#!/bin/sh +set -eu +mkdir -p /run/sshd +if [ ! -f /host-keys/ssh_host_ed25519_key ]; then + ssh-keygen -q -t ed25519 -N '' -f /host-keys/ssh_host_ed25519_key +fi +exec /usr/sbin/sshd -D -e diff --git a/deploy/sshd_config b/deploy/sshd_config new file mode 100644 index 0000000..bad679b --- /dev/null +++ b/deploy/sshd_config @@ -0,0 +1,33 @@ +Port 2222 +ListenAddress 0.0.0.0 +HostKey /host-keys/ssh_host_ed25519_key +PidFile /run/sshd.pid +PermitRootLogin no +PasswordAuthentication no +KbdInteractiveAuthentication no +PermitEmptyPasswords no +PubkeyAuthentication yes +AuthenticationMethods publickey +PubkeyAcceptedAlgorithms ssh-ed25519 +AllowUsers git +AuthorizedKeysFile none +AuthorizedKeysCommand /usr/local/bin/chublog-authorized-keys +AuthorizedKeysCommandUser root +AllowAgentForwarding no +AllowTcpForwarding no +AllowStreamLocalForwarding no +GatewayPorts no +PermitTunnel no +X11Forwarding no +PermitTTY no +PermitUserEnvironment no +PermitUserRC no +DisableForwarding yes +MaxAuthTries 3 +MaxSessions 1 +MaxStartups 3:50:10 +LoginGraceTime 20 +ClientAliveInterval 15 +ClientAliveCountMax 3 +LogLevel VERBOSE +UsePAM no diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..961cc21 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,30 @@ +# 后端 API + +全部正文使用 JSON。同源 `Origin` 必须等于 `PUBLIC_ORIGIN`,例如 `https://blog.example.com`。响应含 `Cache-Control: no-store`。登录 Cookie 为 `HttpOnly; SameSite=Strict; Secure`,仅 localhost HTTP 开发场景省略 Secure。 + +| 方法 | 路径 | 权限 / 请求 | +| --- | --- | --- | +| GET | `/api/health` | 公共,API 存活,不代表数据库或发布同步健康 | +| GET | `/api/posts` | 公共,`{commit,posts:[metadata]}`,仅已发布,日期倒序 | +| GET | `/api/posts/:slug` | 公共,`{meta,markdown,assets:[{name,url}]}`,草稿及不存在均 404 | +| GET | `/api/assets/:slug/:name` | 公共,仅已发布文章的已验证图片 | +| GET | `/api/stats` | 公共,文章数、标签数、正文源字节数、启动以来文章请求数 | +| POST | `/api/auth/register` | 公共 + Origin;`{username,password}`,创建 reader;可配置关闭 | +| POST | `/api/auth/login` | 公共 + Origin;`{username,password}`,返回 `{username,role,csrf}` 及 Cookie | +| POST | `/api/auth/logout` | 登录 + Origin + CSRF;使会话失效并清除 Cookie | +| GET | `/api/account` | 登录;返回自己的 `{username,permissions,csrf}`,不含口令哈希 | +| PUT | `/api/account/ssh-key` | 登录 + Origin + CSRF;`{public_key}`,替换自己的唯一 Ed25519 公钥 | +| PUT | `/api/account/password` | 登录 + Origin + CSRF;`{old_password,new_password}`,成功后全部会话失效 | +| GET | `/api/admin/users` | 管理员;返回用户启用状态、角色、前缀及公钥,不含哈希 | +| PATCH | `/api/admin/users/:username` | 管理员 + Origin + CSRF;`{enabled,role,prefixes}` | +| GET | `/api/admin/status` | 管理员;`{commit,total_posts,drafts,indexed_commit}` | + +角色:`reader | publisher | admin`。网站管理员与数据库账号、OS 用户不是同一个系统。发布角色仍需已登记公钥和非空文章前缀才能使用 SSH。网站登录不产生 Git 访问凭证。 + +用户名:3–32 位小写字母、数字、短横线,以字母开头;口令:12–1024 字节;前缀:2–64 位小写英文、数字、短横线且以短横线结尾,最多 8 个。注册不接受 `role` 等额外字段。公钥不能与别的用户重复。 + +修改请求的头为 `Origin: https://blog.example.com` 和 `X-CSRF-Token: <登录返回的 csrf>`,并携带 Cookie。没有配置跨域 CORS,也没有浏览器本地持久存储令牌的要求。授权或停用用户会使该用户既有会话失效;再次登录后才能继续使用自己的账户接口。 + +普通业务错误返回 `{ "error": "可读说明" }`;401 未登录/口令错误,403 Origin/CSRF/权限错误,404 不存在或非公开内容,409 用户名冲突,429 超过限流。Axum 的无效 JSON/字段结构错误使用框架的 400/415/422 响应,超过请求体上限返回 413。服务器内部错误不返回路径、凭据或数据库详情。 + +文章写入、上传、在线预览、后台发布/删除接口当前都不存在。文章修改和权限审批分开:管理员只能通过账号 API 批准身份与命名空间;文章在作者本机编辑,通过 SSH Git 发布。 diff --git a/docs/decisions/0001-git-only-publishing.md b/docs/decisions/0001-git-only-publishing.md new file mode 100644 index 0000000..054667f --- /dev/null +++ b/docs/decisions/0001-git-only-publishing.md @@ -0,0 +1,21 @@ +# ADR-001:SSH Git 是当前唯一文章写入口 + +## 状态与日期 + +已接受,2026-10-15。以用户后续补充需求为准,调整 PLAN.md 的后台先行方案。 + +## 决定 + +当前只做 Rust/Axum 后端。网站账户注册后为 reader;公钥登记和管理员发布授权分别完成。OpenSSH 使用固定 `AuthorizedKeysCommand` 动态读取独立授权文件,为每把允许的公钥返回绑定站内用户名的强制命令。OS 只有一个无交互权限的 git 账户,网站用户不能自行决定命令或仓库路径。 + +接收仓库与正式内容仓库分属不同进程身份。pre-receive 在 Git quarantine 内验证全部候选文件、单提交历史和改动命名空间;API 再验证并导入独立正式仓库,从该 Git 生成已发布快照。ChuSQL 只保存可重建的公开文章元数据与标签,HTTP 读取不依赖数据库。 + +账户口令哈希保存于 API 私有文件,发布权限/公钥保存于独立只读给 SSH 的 JSON 文件;授权原子替换,SSH 每次认证与接收均重新检查。密码不进入 ChuSQL、文章 Git 或 SSH 容器。该选择避免让实验数据库承担不可重建身份数据,也避免把密码哈希暴露给 SSH 进程。 + +## 取舍 + +暂不提供在线文章编辑入口,避免两条路径同时修改内容。先用 slug 前缀做权限边界,不引入复杂角色体系。单次只接收一个线性提交,便于验证历史,不允许将危险中间提交夹带进仓库。所有授权发布者共享读取仓库历史,草稿不对作者之间隔离。 + +文件账户存储适合单实例、最多 1000 个用户的本轮实现。用户文件与权限文件需要一起备份;不支持多副本账号写入。注册中途失败可能留下没有凭据的 reader 授权条目,但不能登录或发布,重试注册可覆盖。发布授权变更仅涉及单份权限文件,不存在哈希与权限双写导致的旧发布权残留。 + +未来引入前端、在线编辑、多实例或更强审核时应单独记录新决策,并继续保留唯一发布协调点。 diff --git a/scripts/prepare-deploy.py b/scripts/prepare-deploy.py new file mode 100755 index 0000000..c95d500 --- /dev/null +++ b/scripts/prepare-deploy.py @@ -0,0 +1,24 @@ +#!/usr/bin/env python3 +"""Build the API and copy existing local ChuSQL release artifacts into the Docker context.""" +import argparse +from pathlib import Path +import shutil +import subprocess + +root = Path(__file__).resolve().parents[1] +parser = argparse.ArgumentParser(description=__doc__) +parser.add_argument('--no-build', action='store_true', help='use an existing API release binary') +args = parser.parse_args() +if not args.no_build: + subprocess.run(['cargo', 'build', '--locked', '--release'], cwd=root, check=True) +out = root / 'deploy' / 'bin' +out.mkdir(parents=True, exist_ok=True) +shutil.copy2(root / 'target/release/chublog-api', out / 'chublog-api') +chusql = root.parent / 'ChuSQL' +for package, executable in [('chusql-server', 'chusql-server'), ('chusql-bootstrap', 'csql-bootstrap')]: + matches = list((chusql / package / '.stack-work/install').glob(f'*/**/bin/{executable}')) + if len(matches) != 1: + raise SystemExit(f'Expected one built {executable}, found {len(matches)}; build {package} first using its stack.yaml.') + shutil.copy2(matches[0], out / executable) +shutil.copy2(chusql / 'chusql-core/storage/target/release/libchusql_core_storage.so', out) +print('Prepared deploy/bin. Build and run Compose from this same Linux architecture (Debian 13).') diff --git a/scripts/smoke.py b/scripts/smoke.py new file mode 100755 index 0000000..b898609 --- /dev/null +++ b/scripts/smoke.py @@ -0,0 +1,160 @@ +#!/usr/bin/env python3 +"""Exercise real Docker, SSH, Git, HTTP authorization, database outage, and restart recovery. +Uses an isolated Compose project and removes only that project's containers/volumes. +Run prepare-deploy.py first. Ports 18080, 18443, 12222 must be free. +""" +import http.cookiejar +import json +import os +from pathlib import Path +import secrets +import subprocess +import tempfile +import time +import urllib.error +import urllib.request + +ROOT = Path(__file__).resolve().parents[1] +ORIGIN = 'http://localhost:18080' + + +def run(args, **kwargs): + return subprocess.run(args, check=True, text=True, **kwargs) + + +def wait_for(check, timeout=90): + end = time.monotonic() + timeout + last = None + while time.monotonic() < end: + try: + value = check() + if value: + return value + except (OSError, ValueError, urllib.error.HTTPError, AssertionError) as error: + last = error + time.sleep(1) + raise AssertionError(f'Timed out: {last}') + + +class Client: + def __init__(self): + self.opener = urllib.request.build_opener(urllib.request.HTTPCookieProcessor(http.cookiejar.CookieJar())) + self.csrf = None + + def call(self, method, path, data=None, expected=200): + headers = {'Origin': ORIGIN, 'Content-Type': 'application/json'} + if self.csrf: + headers['X-CSRF-Token'] = self.csrf + req = urllib.request.Request(ORIGIN + path, method=method, headers=headers, + data=json.dumps(data).encode() if data is not None else None) + try: + response = self.opener.open(req, timeout=10) + except urllib.error.HTTPError as error: + assert error.code == expected, (path, error.code, error.read()) + return None + assert response.status == expected, (path, response.status) + body = response.read() + return json.loads(body) if body else None + + +with tempfile.TemporaryDirectory(prefix='chublog-smoke-') as temporary: + tmp = Path(temporary) + password = secrets.token_urlsafe(24) + hashed = run([str(ROOT / 'deploy/bin/chublog-api'), 'hash-password'], input=password, capture_output=True).stdout.strip() + envfile = tmp / '.env' + envfile.write_text(f"PUBLIC_ORIGIN={ORIGIN}\nSITE_ADDRESS=http://localhost:80\nADMIN_PASSWORD_HASH='{hashed}'\n" + f"CHUSQL_ROOT_PASSWORD={secrets.token_urlsafe(24)}\nCHUSQL_PASSWORD={secrets.token_urlsafe(24)}\n" + 'DEBIAN_MIRROR=mirrors.ustc.edu.cn\nALLOW_REGISTRATION=true\nHTTP_BIND=127.0.0.1\nHTTP_PORT=18080\nHTTPS_PORT=18443\nSSH_BIND=127.0.0.1\nSSH_PORT=12222\n') + envfile.chmod(0o600) + project = 'chublog-test-' + secrets.token_hex(4) + compose = ['docker', 'compose', '--env-file', str(envfile), '-p', project, '-f', str(ROOT / 'deploy/compose.yaml')] + try: + run(compose + ['up', '-d', '--build']) + admin = Client() + wait_for(lambda: admin.call('GET', '/api/health')) + wait_for(lambda: subprocess.run(compose + ['run', '--rm', 'db-init'], capture_output=True).returncode == 0) + admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf'] + publisher = Client() + publisher.call('POST', '/api/auth/register', {'username': 'alice', 'password': password}) + publisher.csrf = publisher.call('POST', '/api/auth/login', {'username': 'alice', 'password': password})['csrf'] + run(['ssh-keygen', '-q', '-t', 'ed25519', '-N', '', '-f', str(tmp / 'key')]) + publisher.call('PUT', '/api/account/ssh-key', {'public_key': (tmp / 'key.pub').read_text().strip()}) + publisher.call('GET', '/api/admin/users', expected=403) + env = os.environ.copy() + env['GIT_SSH_COMMAND'] = f'ssh -i {tmp / "key"} -o IdentitiesOnly=yes -o StrictHostKeyChecking=accept-new -o UserKnownHostsFile={tmp / "known_hosts"} -p 12222' + local = tmp / 'articles' + run(['git', 'init', '--initial-branch=main', str(local)], capture_output=True) + run(['git', '-C', str(local), 'config', 'user.name', 'Alice']) + run(['git', '-C', str(local), 'config', 'user.email', 'alice@example.invalid']) + run(['git', '-C', str(local), 'remote', 'add', 'origin', 'git@localhost:content.git']) + article = local / 'posts/alice-first/index.md' + article.parent.mkdir(parents=True) + original = (ROOT / 'content/posts/welcome/index.md').read_text().replace('slug: "welcome"', 'slug: "alice-first"') + article.write_text(original) + + def commit(): + run(['git', '-C', str(local), 'add', '.']) + run(['git', '-C', str(local), 'commit', '-m', 'Update article'], capture_output=True) + + def push(success=True, *extra): + result = subprocess.run(['git', '-C', str(local), 'push', 'origin', *extra] if extra else ['git', '-C', str(local), 'push', '-u', 'origin', 'main'], env=env, capture_output=True, text=True) + assert (result.returncode == 0) == success, result.stderr + return result + + commit() + push(False) + admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'publisher', 'prefixes': ['alice-']}) + push() + wait_for(lambda: Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == 'alice-first') + wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120) + print('PASS: registration, independent grant, real SSH push, publication, real ChuSQL index') + ssh = ['ssh', '-i', str(tmp / 'key'), '-o', 'IdentitiesOnly=yes', '-o', 'StrictHostKeyChecking=accept-new', '-o', f'UserKnownHostsFile={tmp / "known_hosts"}', '-p', '12222', 'git@localhost'] + result = subprocess.run(ssh + ['id'], capture_output=True) + assert result.returncode != 0, 'SSH shell command unexpectedly succeeded' + # Invalid commits must not advance the remote branch. Reset only this disposable local test clone. + for kind in ['script', 'symlink', 'foreign', 'oversize', 'pack']: + if kind == 'script': + article.write_text(original + '\n\n') + elif kind == 'symlink': + article.unlink() + article.symlink_to('/etc/passwd') + elif kind == 'foreign': + other = local / 'posts/bob-first/index.md' + other.parent.mkdir(parents=True) + other.write_text(original.replace('alice-first', 'bob-first')) + elif kind == 'oversize': + article.write_text(original + 'x' * (257 * 1024)) + else: + (local / 'oversized.bin').write_bytes(os.urandom(9 * 1024 * 1024)) + commit() + push(False) + run(['git', '-C', str(local), 'reset', '--hard', 'origin/main'], capture_output=True) + push(False, 'main:refs/heads/other') + push(False, '--delete', 'main') + print('PASS: arbitrary command, HTML, symlink, other-author edit, oversized article, extra branch and branch deletion rejected') + article.write_text(original.replace('status: "published"', 'status: "draft"')) + commit() + push() + wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 0) + Client().call('GET', '/api/posts/alice-first', expected=404) + article.write_text(original) + commit() + push() + wait_for(lambda: Client().call('GET', '/api/stats')['published_posts'] == 1) + run(compose + ['stop', 'db']) + assert Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == 'alice-first' + run(compose + ['restart', 'api']) + wait_for(lambda: Client().call('GET', '/api/posts/alice-first')['meta']['slug'] == 'alice-first') + admin.csrf = admin.call('POST', '/api/auth/login', {'username': 'admin', 'password': password})['csrf'] + admin.call('PATCH', '/api/admin/users/alice', {'enabled': True, 'role': 'reader', 'prefixes': []}) + article.write_text(original + '\nRevoked publisher\n') + commit() + push(False) + run(compose + ['start', 'db']) + wait_for(lambda: (lambda s: s['indexed_commit'] == s['commit'])(admin.call('GET', '/api/admin/status')), timeout=120) + print('PASS: draft privacy, database outage, API restart recovery, revocation, automatic index recovery') + except Exception: + run(compose + ['logs', '--tail', '40']) + raise + finally: + run(compose + ['down', '--volumes', '--remove-orphans'])