commit a631d75cee0fe749c93eef9049add8de41fd7077 Author: TheBadRoger Date: Thu Oct 15 20:08:15 2026 +0800 Initialize ChuBlog backend with SSH Git publishing 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'])