Compare commits
15 Commits
bd9e87128d
...
ee6aaf179c
| Author | SHA1 | Date | |
|---|---|---|---|
| ee6aaf179c | |||
| 835d71972c | |||
| a10bf8737c | |||
| b898b55308 | |||
| f8fb35f8c2 | |||
| 1c3f1aac34 | |||
| 8840faa830 | |||
| c62cf5a239 | |||
| 67e23d672c | |||
| 0219f923e9 | |||
| f6589121da | |||
| 2e9d123396 | |||
| a975e45cc9 | |||
| acd85d18ce | |||
| 7244c39a47 |
2
.gitignore
vendored
2
.gitignore
vendored
@ -17,3 +17,5 @@ docs/superpowers/
|
|||||||
uploads/*
|
uploads/*
|
||||||
!uploads/.gitkeep
|
!uploads/.gitkeep
|
||||||
uploads/.cache/
|
uploads/.cache/
|
||||||
|
|
||||||
|
profile.json.gz
|
||||||
|
|||||||
226
Cargo.lock
generated
226
Cargo.lock
generated
@ -23,19 +23,6 @@ version = "0.2.21"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "ammonia"
|
|
||||||
version = "4.1.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6"
|
|
||||||
dependencies = [
|
|
||||||
"cssparser",
|
|
||||||
"html5ever",
|
|
||||||
"maplit",
|
|
||||||
"tendril",
|
|
||||||
"url",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "android_system_properties"
|
name = "android_system_properties"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -721,14 +708,14 @@ dependencies = [
|
|||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "cssparser"
|
name = "cssparser"
|
||||||
version = "0.35.0"
|
version = "0.36.0"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa"
|
checksum = "dae61cf9c0abb83bd659dab65b7e4e38d8236824c85f0f804f173567bda257d2"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"cssparser-macros",
|
"cssparser-macros",
|
||||||
"dtoa-short",
|
"dtoa-short",
|
||||||
"itoa",
|
"itoa",
|
||||||
"phf 0.11.3",
|
"phf",
|
||||||
"smallvec",
|
"smallvec",
|
||||||
]
|
]
|
||||||
|
|
||||||
@ -1636,6 +1623,12 @@ dependencies = [
|
|||||||
"regex-syntax",
|
"regex-syntax",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fastrand"
|
||||||
|
version = "2.4.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "fdeflate"
|
name = "fdeflate"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
@ -1688,16 +1681,6 @@ dependencies = [
|
|||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "futf"
|
|
||||||
version = "0.1.5"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "df420e2e84819663797d1ec6544b13c5be84629e7bb00dc960d6917db2987843"
|
|
||||||
dependencies = [
|
|
||||||
"mac",
|
|
||||||
"new_debug_unreachable",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "futures"
|
name = "futures"
|
||||||
version = "0.3.32"
|
version = "0.3.32"
|
||||||
@ -2002,6 +1985,11 @@ name = "hashbrown"
|
|||||||
version = "0.17.1"
|
version = "0.17.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
dependencies = [
|
||||||
|
"allocator-api2",
|
||||||
|
"equivalent",
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "headers"
|
name = "headers"
|
||||||
@ -2054,17 +2042,6 @@ dependencies = [
|
|||||||
"digest 0.11.3",
|
"digest 0.11.3",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "html5ever"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "55d958c2f74b664487a2035fe1dadb032c48718a03b63f3ab0b8537db8549ed4"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"markup5ever",
|
|
||||||
"match_token",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "http"
|
name = "http"
|
||||||
version = "1.4.1"
|
version = "1.4.1"
|
||||||
@ -2556,6 +2533,25 @@ version = "0.4.32"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
checksum = "953f07c43838f8e6f9758cab68bf5bed85465e7587ebe0b823f1bcd81978ad3a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "lol_html"
|
||||||
|
version = "2.9.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "00aad58f6ec3990e795943872f13651e7a5fa59dca2c8f31a74faf8a0e0fb652"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cfg-if",
|
||||||
|
"cssparser",
|
||||||
|
"encoding_rs",
|
||||||
|
"foldhash 0.2.0",
|
||||||
|
"hashbrown 0.17.1",
|
||||||
|
"memchr",
|
||||||
|
"mime",
|
||||||
|
"precomputed-hash",
|
||||||
|
"selectors",
|
||||||
|
"thiserror 2.0.18",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "longest-increasing-subsequence"
|
name = "longest-increasing-subsequence"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
@ -2577,12 +2573,6 @@ version = "0.1.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "mac"
|
|
||||||
version = "0.1.1"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4"
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "macro-string"
|
name = "macro-string"
|
||||||
version = "0.1.4"
|
version = "0.1.4"
|
||||||
@ -2647,34 +2637,6 @@ dependencies = [
|
|||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "maplit"
|
|
||||||
version = "1.0.2"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "markup5ever"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "311fe69c934650f8f19652b3946075f0fc41ad8757dbb68f1ca14e7900ecc1c3"
|
|
||||||
dependencies = [
|
|
||||||
"log",
|
|
||||||
"tendril",
|
|
||||||
"web_atoms",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "match_token"
|
|
||||||
version = "0.35.0"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf"
|
|
||||||
dependencies = [
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
"syn",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "matchers"
|
name = "matchers"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
@ -3007,68 +2969,50 @@ version = "2.3.2"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "1fd6780a80ae0c52cc120a26a1a42c1ae51b247a253e4e06113d23d2c2edd078"
|
|
||||||
dependencies = [
|
|
||||||
"phf_macros",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf"
|
name = "phf"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared 0.13.1",
|
"phf_macros",
|
||||||
|
"phf_shared",
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_codegen"
|
name = "phf_codegen"
|
||||||
version = "0.11.3"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "aef8048c789fa5e851558d709946d6d79a8ff88c0440c587967f8e94bfb1216a"
|
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared 0.11.3",
|
"phf_shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_generator"
|
name = "phf_generator"
|
||||||
version = "0.11.3"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_shared 0.11.3",
|
"fastrand",
|
||||||
"rand 0.8.6",
|
"phf_shared",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_macros"
|
name = "phf_macros"
|
||||||
version = "0.11.3"
|
version = "0.13.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "f84ac04429c13a7ff43785d75ad27569f2951ce0ffd30a3321230db2fc727216"
|
checksum = "812f032b54b1e759ccd5f8b6677695d5268c588701effba24601f6932f8269ef"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"phf_generator",
|
"phf_generator",
|
||||||
"phf_shared 0.11.3",
|
"phf_shared",
|
||||||
"proc-macro2",
|
"proc-macro2",
|
||||||
"quote",
|
"quote",
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "phf_shared"
|
|
||||||
version = "0.11.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "67eabc2ef2a60eb7faa00097bd1ffdb5bd28e62bf39990626a582201b7a754e5"
|
|
||||||
dependencies = [
|
|
||||||
"siphasher",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "phf_shared"
|
name = "phf_shared"
|
||||||
version = "0.13.1"
|
version = "0.13.1"
|
||||||
@ -3675,6 +3619,25 @@ version = "1.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "selectors"
|
||||||
|
version = "0.37.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2cfaaa6035167f0e604e42723c7650d59ee269ef220d7bbe0565602c8a0173b9"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cssparser",
|
||||||
|
"derive_more",
|
||||||
|
"log",
|
||||||
|
"new_debug_unreachable",
|
||||||
|
"phf",
|
||||||
|
"phf_codegen",
|
||||||
|
"precomputed-hash",
|
||||||
|
"rustc-hash 2.1.2",
|
||||||
|
"servo_arc",
|
||||||
|
"smallvec",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "semver"
|
name = "semver"
|
||||||
version = "1.0.28"
|
version = "1.0.28"
|
||||||
@ -3789,6 +3752,15 @@ dependencies = [
|
|||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "servo_arc"
|
||||||
|
version = "0.4.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "170fb83ab34de17dc69aa7c67482b22218ddb85da56546f9bd6b929e32a05930"
|
||||||
|
dependencies = [
|
||||||
|
"stable_deref_trait",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "sha1"
|
name = "sha1"
|
||||||
version = "0.10.6"
|
version = "0.10.6"
|
||||||
@ -3931,31 +3903,6 @@ version = "1.2.1"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "string_cache"
|
|
||||||
version = "0.8.9"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "bf776ba3fa74f83bf4b63c3dcbbf82173db2632ed8452cb2d891d33f459de70f"
|
|
||||||
dependencies = [
|
|
||||||
"new_debug_unreachable",
|
|
||||||
"parking_lot",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"precomputed-hash",
|
|
||||||
"serde",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "string_cache_codegen"
|
|
||||||
version = "0.5.4"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "c711928715f1fe0fe509c53b43e993a9a557babc2d0a3567d0a3006f1ac931a0"
|
|
||||||
dependencies = [
|
|
||||||
"phf_generator",
|
|
||||||
"phf_shared 0.11.3",
|
|
||||||
"proc-macro2",
|
|
||||||
"quote",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "stringprep"
|
name = "stringprep"
|
||||||
version = "0.1.5"
|
version = "0.1.5"
|
||||||
@ -4080,17 +4027,6 @@ version = "0.2.0"
|
|||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
checksum = "7b2093cf4c8eb1e67749a6762251bc9cd836b6fc171623bd0a9d324d37af2417"
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "tendril"
|
|
||||||
version = "0.4.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "d24a120c5fc464a3458240ee02c299ebcb9d67b5249c8848b09d639dca8d7bb0"
|
|
||||||
dependencies = [
|
|
||||||
"futf",
|
|
||||||
"mac",
|
|
||||||
"utf-8",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "thiserror"
|
name = "thiserror"
|
||||||
version = "1.0.69"
|
version = "1.0.69"
|
||||||
@ -4237,7 +4173,7 @@ dependencies = [
|
|||||||
"log",
|
"log",
|
||||||
"parking_lot",
|
"parking_lot",
|
||||||
"percent-encoding",
|
"percent-encoding",
|
||||||
"phf 0.13.1",
|
"phf",
|
||||||
"pin-project-lite",
|
"pin-project-lite",
|
||||||
"postgres-protocol",
|
"postgres-protocol",
|
||||||
"postgres-types",
|
"postgres-types",
|
||||||
@ -4848,18 +4784,6 @@ dependencies = [
|
|||||||
"wasm-bindgen",
|
"wasm-bindgen",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
|
||||||
name = "web_atoms"
|
|
||||||
version = "0.1.3"
|
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
|
||||||
checksum = "57ffde1dc01240bdf9992e3205668b235e59421fd085e8a317ed98da0178d414"
|
|
||||||
dependencies = [
|
|
||||||
"phf 0.11.3",
|
|
||||||
"phf_codegen",
|
|
||||||
"string_cache",
|
|
||||||
"string_cache_codegen",
|
|
||||||
]
|
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "webpki-roots"
|
name = "webpki-roots"
|
||||||
version = "1.0.7"
|
version = "1.0.7"
|
||||||
@ -5354,7 +5278,6 @@ dependencies = [
|
|||||||
name = "yggdrasil"
|
name = "yggdrasil"
|
||||||
version = "0.2.0"
|
version = "0.2.0"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"ammonia",
|
|
||||||
"argon2",
|
"argon2",
|
||||||
"axum",
|
"axum",
|
||||||
"chrono",
|
"chrono",
|
||||||
@ -5367,6 +5290,7 @@ dependencies = [
|
|||||||
"http",
|
"http",
|
||||||
"image",
|
"image",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"lol_html",
|
||||||
"md-5 0.10.6",
|
"md-5 0.10.6",
|
||||||
"moka",
|
"moka",
|
||||||
"pulldown-cmark",
|
"pulldown-cmark",
|
||||||
|
|||||||
@ -25,7 +25,7 @@ axum = { version = "0.8", optional = true, features = ["multipart"] }
|
|||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
sha2 = "0.10"
|
sha2 = "0.10"
|
||||||
hex = "0.4"
|
hex = "0.4"
|
||||||
ammonia = { version = "4", optional = true }
|
lol_html = { version = "2", optional = true }
|
||||||
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load", "yaml-load"], optional = true }
|
syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "default-fancy", "html", "parsing", "dump-load", "yaml-load"], optional = true }
|
||||||
# NOTE: WebP decoder is intentionally excluded from the image crate.
|
# NOTE: WebP decoder is intentionally excluded from the image crate.
|
||||||
# We use zenwebp for both encoding and decoding to ensure consistency.
|
# We use zenwebp for both encoding and decoding to ensure consistency.
|
||||||
@ -63,7 +63,7 @@ server = [
|
|||||||
"dep:tracing",
|
"dep:tracing",
|
||||||
"dep:tracing-subscriber",
|
"dep:tracing-subscriber",
|
||||||
"dep:tower-http",
|
"dep:tower-http",
|
||||||
"dep:ammonia",
|
"dep:lol_html",
|
||||||
"dep:syntect",
|
"dep:syntect",
|
||||||
"dep:axum",
|
"dep:axum",
|
||||||
"dep:image",
|
"dep:image",
|
||||||
|
|||||||
66
DEVELOPMENT.md
Normal file
66
DEVELOPMENT.md
Normal file
@ -0,0 +1,66 @@
|
|||||||
|
# Development Guide
|
||||||
|
|
||||||
|
## Performance Testing
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
Install hey (HTTP load generator) and samply (profiler):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
brew install hey
|
||||||
|
cargo install samply
|
||||||
|
```
|
||||||
|
|
||||||
|
### Benchmark
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build release binary
|
||||||
|
make build
|
||||||
|
|
||||||
|
# 2. Start server
|
||||||
|
./target/dx/yggdrasil/release/web/server
|
||||||
|
|
||||||
|
# 3. Run load test (in another terminal)
|
||||||
|
hey -c 100 -n 100000 http://localhost:8080/
|
||||||
|
```
|
||||||
|
|
||||||
|
### Flame Graph
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# 1. Build with debug symbols (required for readable flame graphs)
|
||||||
|
CARGO_PROFILE_RELEASE_DEBUG=1 make build
|
||||||
|
|
||||||
|
# Terminal 1: Start profiling
|
||||||
|
samply record -- ./target/dx/yggdrasil/release/web/server
|
||||||
|
|
||||||
|
# Terminal 2: Wait for server to start, then send load
|
||||||
|
hey -c 100 -n 100000 http://localhost:8080/
|
||||||
|
|
||||||
|
# Terminal 1: Ctrl+C after hey finishes — samply opens flame graph in browser
|
||||||
|
```
|
||||||
|
|
||||||
|
### Key Metrics to Watch
|
||||||
|
|
||||||
|
| Metric | Description |
|
||||||
|
|--------|-------------|
|
||||||
|
| Requests/sec | Throughput |
|
||||||
|
| Average latency | Mean response time |
|
||||||
|
| P99 latency | Tail latency |
|
||||||
|
| Status codes | Error rate (should be 0) |
|
||||||
|
| Latency distribution | Consistency (tight = stable) |
|
||||||
|
|
||||||
|
### Flame Graph Hotspots
|
||||||
|
|
||||||
|
| Expected Hotspot | Code Location | Cause |
|
||||||
|
|------------------|---------------|-------|
|
||||||
|
| SSR rendering | Dioxus framework | Virtual DOM diff + render per request |
|
||||||
|
| `deadpool` connection acquisition | `src/db/mod.rs` | Connection pool contention under concurrency |
|
||||||
|
| `moka` cache lookup | `src/cache.rs` | Cache hit/miss overhead |
|
||||||
|
| `tokio` scheduling | tokio runtime | Async task dispatch |
|
||||||
|
| `serde` serialization | Models | Post/User serialization |
|
||||||
|
|
||||||
|
### Tuning
|
||||||
|
|
||||||
|
- `DB_POOL_SIZE` — increase if `deadpool` / `Semaphore` shows high in flame graph
|
||||||
|
- `SSR_CACHE_SECS` — increase to cache SSR output longer
|
||||||
|
- `TOKIO_WORKER_THREADS` — explicitly set worker thread count
|
||||||
1
migrations/005_add_toc_html.sql
Normal file
1
migrations/005_add_toc_html.sql
Normal file
@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE posts ADD COLUMN toc_html TEXT;
|
||||||
@ -8,29 +8,9 @@ fn html_escape(s: &str) -> String {
|
|||||||
.replace('"', """)
|
.replace('"', """)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
static COMMENT_AMMONIA_BUILDER: std::sync::LazyLock<ammonia::Builder> = std::sync::LazyLock::new(|| {
|
|
||||||
let mut builder = ammonia::Builder::default();
|
|
||||||
builder
|
|
||||||
.rm_tags(["img", "details", "summary"])
|
|
||||||
.add_generic_attributes(&[
|
|
||||||
"class",
|
|
||||||
"title",
|
|
||||||
"aria-hidden",
|
|
||||||
"aria-label",
|
|
||||||
"role",
|
|
||||||
"accesskey",
|
|
||||||
])
|
|
||||||
.url_relative(ammonia::UrlRelative::PassThrough)
|
|
||||||
.add_tag_attributes("a", &["class", "aria-hidden", "aria-label"])
|
|
||||||
.add_tag_attributes("span", &["class"])
|
|
||||||
.link_rel(Some("nofollow noopener"));
|
|
||||||
builder
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn clean_comment_html(input: &str) -> String {
|
pub fn clean_comment_html(input: &str) -> String {
|
||||||
COMMENT_AMMONIA_BUILDER.clean(input).to_string()
|
crate::api::sanitizer::clean_comment_html(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
|
|||||||
@ -1,35 +1,8 @@
|
|||||||
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
|
||||||
static AMMONIA_BUILDER: std::sync::LazyLock<ammonia::Builder> = std::sync::LazyLock::new(|| {
|
|
||||||
let mut builder = ammonia::Builder::default();
|
|
||||||
builder
|
|
||||||
.add_generic_attributes(&[
|
|
||||||
"class",
|
|
||||||
"aria-hidden",
|
|
||||||
"aria-label",
|
|
||||||
"id",
|
|
||||||
"role",
|
|
||||||
"accesskey",
|
|
||||||
"title",
|
|
||||||
])
|
|
||||||
.add_tags(&["details", "summary"])
|
|
||||||
.url_relative(ammonia::UrlRelative::PassThrough)
|
|
||||||
.add_url_schemes(&["data"])
|
|
||||||
.add_tag_attributes("a", &["class", "aria-hidden", "aria-label"])
|
|
||||||
.add_tag_attributes("span", &["class"])
|
|
||||||
.add_tag_attributes("h1", &["id", "class"])
|
|
||||||
.add_tag_attributes("h2", &["id", "class"])
|
|
||||||
.add_tag_attributes("h3", &["id", "class"])
|
|
||||||
.add_tag_attributes("h4", &["id", "class"])
|
|
||||||
.add_tag_attributes("h5", &["id", "class"])
|
|
||||||
.add_tag_attributes("h6", &["id", "class"]);
|
|
||||||
builder
|
|
||||||
});
|
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub fn clean_html(input: &str) -> String {
|
pub fn clean_html(input: &str) -> String {
|
||||||
AMMONIA_BUILDER.clean(input).to_string()
|
crate::api::sanitizer::clean_html(input)
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
@ -228,11 +201,12 @@ fn generate_toc_html(headings: &[(u8, String, String)]) -> String {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let clean_text = clean_html(text);
|
||||||
html.push_str(&format!(
|
html.push_str(&format!(
|
||||||
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
|
"<li><a href=\"#{}\" aria-label=\"{}\">{}</a>",
|
||||||
id,
|
id,
|
||||||
clean_html(text),
|
clean_text,
|
||||||
clean_html(text)
|
clean_text
|
||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -5,5 +5,6 @@ pub mod image;
|
|||||||
pub mod markdown;
|
pub mod markdown;
|
||||||
pub mod posts;
|
pub mod posts;
|
||||||
pub mod rate_limit;
|
pub mod rate_limit;
|
||||||
|
pub mod sanitizer;
|
||||||
pub mod slug;
|
pub mod slug;
|
||||||
pub mod upload;
|
pub mod upload;
|
||||||
|
|||||||
@ -61,6 +61,11 @@ pub async fn create_post(
|
|||||||
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?;
|
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, None).await?;
|
||||||
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
let content_html = rendered.html;
|
let content_html = rendered.html;
|
||||||
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
|
None::<String>
|
||||||
|
} else {
|
||||||
|
Some(rendered.toc_html)
|
||||||
|
};
|
||||||
let summary = summary
|
let summary = summary
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
||||||
@ -77,8 +82,8 @@ pub async fn create_post(
|
|||||||
|
|
||||||
let row = tx
|
let row = tx
|
||||||
.query_one(
|
.query_one(
|
||||||
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, status, published_at, cover_image)
|
"INSERT INTO posts (author_id, title, slug, summary, content_md, content_html, toc_html, status, published_at, cover_image)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||||
RETURNING id",
|
RETURNING id",
|
||||||
&[
|
&[
|
||||||
&user.id,
|
&user.id,
|
||||||
@ -87,6 +92,7 @@ pub async fn create_post(
|
|||||||
&summary,
|
&summary,
|
||||||
&content_md,
|
&content_md,
|
||||||
&content_html,
|
&content_html,
|
||||||
|
&toc_html,
|
||||||
&post_status.as_str(),
|
&post_status.as_str(),
|
||||||
&published_at,
|
&published_at,
|
||||||
&cover_image,
|
&cover_image,
|
||||||
|
|||||||
@ -91,9 +91,26 @@ pub(super) async fn row_to_post_full(
|
|||||||
None
|
None
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let content_html: Option<String> = row.get("content_html");
|
||||||
|
let toc_html_row: Option<String> = row.get("toc_html");
|
||||||
|
|
||||||
|
let (content_html, toc_html) = if let Some(html) = content_html {
|
||||||
|
(html, toc_html_row)
|
||||||
|
} else {
|
||||||
|
let content_md: String = row.get("content_md");
|
||||||
|
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
|
(
|
||||||
|
rendered.html,
|
||||||
|
if rendered.toc_html.is_empty() {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(rendered.toc_html)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
let content_md: String = row.get("content_md");
|
let content_md: String = row.get("content_md");
|
||||||
let word_count = count_words(&content_md);
|
let word_count = count_words(&content_md);
|
||||||
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
|
||||||
|
|
||||||
Post {
|
Post {
|
||||||
id,
|
id,
|
||||||
@ -102,7 +119,7 @@ pub(super) async fn row_to_post_full(
|
|||||||
slug: row.get("slug"),
|
slug: row.get("slug"),
|
||||||
summary: row.get("summary"),
|
summary: row.get("summary"),
|
||||||
content_md,
|
content_md,
|
||||||
content_html: Some(rendered.html),
|
content_html: Some(content_html),
|
||||||
status,
|
status,
|
||||||
published_at: row.get("published_at"),
|
published_at: row.get("published_at"),
|
||||||
created_at: row.get("created_at"),
|
created_at: row.get("created_at"),
|
||||||
@ -111,11 +128,7 @@ pub(super) async fn row_to_post_full(
|
|||||||
cover_image: row.get("cover_image"),
|
cover_image: row.get("cover_image"),
|
||||||
reading_time: (word_count / 200).max(1),
|
reading_time: (word_count / 200).max(1),
|
||||||
word_count,
|
word_count,
|
||||||
toc_html: if rendered.toc_html.is_empty() {
|
toc_html,
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(rendered.toc_html)
|
|
||||||
},
|
|
||||||
prev_post,
|
prev_post,
|
||||||
next_post,
|
next_post,
|
||||||
}
|
}
|
||||||
|
|||||||
@ -10,6 +10,7 @@ mod list;
|
|||||||
mod search;
|
mod search;
|
||||||
mod tags;
|
mod tags;
|
||||||
mod stats;
|
mod stats;
|
||||||
|
mod rebuild;
|
||||||
|
|
||||||
pub use types::*;
|
pub use types::*;
|
||||||
pub use create::create_post;
|
pub use create::create_post;
|
||||||
@ -20,6 +21,7 @@ pub use list::{list_published_posts, list_posts, get_posts_by_tag};
|
|||||||
pub use search::search_posts;
|
pub use search::search_posts;
|
||||||
pub use tags::list_tags;
|
pub use tags::list_tags;
|
||||||
pub use stats::get_post_stats;
|
pub use stats::get_post_stats;
|
||||||
|
pub use rebuild::rebuild_content_html;
|
||||||
|
|
||||||
#[cfg(feature = "server")]
|
#[cfg(feature = "server")]
|
||||||
pub use crate::api::markdown::render_markdown_enhanced;
|
pub use crate::api::markdown::render_markdown_enhanced;
|
||||||
|
|||||||
96
src/api/posts/rebuild.rs
Normal file
96
src/api/posts/rebuild.rs
Normal file
@ -0,0 +1,96 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use super::helpers::get_current_admin_user;
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use crate::api::error::AppError;
|
||||||
|
use crate::api::posts::RebuildResult;
|
||||||
|
use crate::db::pool::get_conn;
|
||||||
|
|
||||||
|
const REBUILD_BATCH_LIMIT: i64 = 500;
|
||||||
|
const MAX_DISPLAY_ERRORS: usize = 5;
|
||||||
|
|
||||||
|
#[server(RebuildContentHtml, "/api")]
|
||||||
|
pub async fn rebuild_content_html(rebuild_all: bool) -> Result<RebuildResult, ServerFnError> {
|
||||||
|
let _user = get_current_admin_user().await?;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
{
|
||||||
|
let client = get_conn().await.map_err(AppError::db_conn)?;
|
||||||
|
|
||||||
|
let query = if rebuild_all {
|
||||||
|
format!(
|
||||||
|
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
format!(
|
||||||
|
"SELECT id, content_md FROM posts WHERE deleted_at IS NULL AND content_html IS NULL ORDER BY id LIMIT {REBUILD_BATCH_LIMIT}"
|
||||||
|
)
|
||||||
|
};
|
||||||
|
|
||||||
|
let rows = client.query(&query, &[]).await.map_err(AppError::query)?;
|
||||||
|
|
||||||
|
let mut rebuilt: u64 = 0;
|
||||||
|
let mut failed: u64 = 0;
|
||||||
|
let mut errors: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
for row in &rows {
|
||||||
|
let id: i32 = row.get(0);
|
||||||
|
let content_md: String = row.get(1);
|
||||||
|
|
||||||
|
let rendered = match std::panic::catch_unwind(|| {
|
||||||
|
crate::api::markdown::render_markdown_enhanced(&content_md)
|
||||||
|
}) {
|
||||||
|
Ok(r) => r,
|
||||||
|
Err(_) => {
|
||||||
|
failed += 1;
|
||||||
|
if errors.len() < MAX_DISPLAY_ERRORS {
|
||||||
|
errors.push(format!("文章 #{id}: 渲染异常"));
|
||||||
|
}
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
|
None::<String>
|
||||||
|
} else {
|
||||||
|
Some(rendered.toc_html)
|
||||||
|
};
|
||||||
|
|
||||||
|
match client
|
||||||
|
.execute(
|
||||||
|
"UPDATE posts SET content_html = $1, toc_html = $2 WHERE id = $3",
|
||||||
|
&[&rendered.html, &toc_html, &id],
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(_) => rebuilt += 1,
|
||||||
|
Err(_) => {
|
||||||
|
failed += 1;
|
||||||
|
if errors.len() < MAX_DISPLAY_ERRORS {
|
||||||
|
errors.push(format!("文章 #{id}: DB 写入失败"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if rebuilt > 0 || failed > 0 {
|
||||||
|
crate::cache::invalidate_all_post_caches();
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(RebuildResult {
|
||||||
|
rebuilt,
|
||||||
|
failed,
|
||||||
|
errors,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(not(feature = "server"))]
|
||||||
|
{
|
||||||
|
Ok(RebuildResult {
|
||||||
|
rebuilt: 0,
|
||||||
|
failed: 0,
|
||||||
|
errors: vec![],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -40,3 +40,10 @@ pub struct PostStatsResponse {
|
|||||||
pub struct SinglePostResponse {
|
pub struct SinglePostResponse {
|
||||||
pub post: Option<Post>,
|
pub post: Option<Post>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
|
||||||
|
pub struct RebuildResult {
|
||||||
|
pub rebuilt: u64,
|
||||||
|
pub failed: u64,
|
||||||
|
pub errors: Vec<String>,
|
||||||
|
}
|
||||||
|
|||||||
@ -68,6 +68,11 @@ pub async fn update_post(
|
|||||||
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
|
let final_slug = crate::api::slug::ensure_unique_slug(&client, &base_slug, Some(post_id)).await?;
|
||||||
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
let rendered = crate::api::markdown::render_markdown_enhanced(&content_md);
|
||||||
let content_html = rendered.html;
|
let content_html = rendered.html;
|
||||||
|
let toc_html = if rendered.toc_html.is_empty() {
|
||||||
|
None::<String>
|
||||||
|
} else {
|
||||||
|
Some(rendered.toc_html)
|
||||||
|
};
|
||||||
let summary = summary
|
let summary = summary
|
||||||
.filter(|s| !s.trim().is_empty())
|
.filter(|s| !s.trim().is_empty())
|
||||||
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
.unwrap_or_else(|| crate::utils::text::auto_summary(&content_md));
|
||||||
@ -116,14 +121,15 @@ pub async fn update_post(
|
|||||||
};
|
};
|
||||||
|
|
||||||
tx.execute(
|
tx.execute(
|
||||||
"UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, status = $6, published_at = $7, cover_image = $8, updated_at = NOW()
|
"UPDATE posts SET title = $1, slug = $2, summary = $3, content_md = $4, content_html = $5, toc_html = $6, status = $7, published_at = $8, cover_image = $9, updated_at = NOW()
|
||||||
WHERE id = $9",
|
WHERE id = $10",
|
||||||
&[
|
&[
|
||||||
&title.trim(),
|
&title.trim(),
|
||||||
&final_slug,
|
&final_slug,
|
||||||
&summary,
|
&summary,
|
||||||
&content_md,
|
&content_md,
|
||||||
&content_html,
|
&content_html,
|
||||||
|
&toc_html,
|
||||||
&post_status.as_str(),
|
&post_status.as_str(),
|
||||||
&published_at,
|
&published_at,
|
||||||
&cover_image,
|
&cover_image,
|
||||||
|
|||||||
261
src/api/sanitizer.rs
Normal file
261
src/api/sanitizer.rs
Normal file
@ -0,0 +1,261 @@
|
|||||||
|
#![allow(clippy::unused_unit, deprecated, unused_imports)]
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn default_allowed_tags() -> HashSet<&'static str> {
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
for tag in [
|
||||||
|
"a", "abbr", "acronym", "area", "article", "aside", "b", "bdi",
|
||||||
|
"bdo", "blockquote", "br", "caption", "center", "cite", "code",
|
||||||
|
"col", "colgroup", "data", "dd", "del", "details", "dfn", "div",
|
||||||
|
"dl", "dt", "em", "figcaption", "figure", "footer", "h1", "h2",
|
||||||
|
"h3", "h4", "h5", "h6", "header", "hgroup", "hr", "i", "img",
|
||||||
|
"ins", "kbd", "li", "map", "mark", "nav", "ol", "p", "pre",
|
||||||
|
"q", "rp", "rt", "rtc", "ruby", "s", "samp", "small", "span",
|
||||||
|
"strike", "strong", "sub", "summary", "sup", "table", "tbody",
|
||||||
|
"td", "th", "thead", "time", "tr", "tt", "u", "ul", "var", "wbr",
|
||||||
|
] {
|
||||||
|
set.insert(tag);
|
||||||
|
}
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn clean_content_tags() -> HashSet<&'static str> {
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
set.insert("script");
|
||||||
|
set.insert("style");
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn default_allowed_schemes() -> HashSet<&'static str> {
|
||||||
|
let mut set = HashSet::new();
|
||||||
|
for scheme in [
|
||||||
|
"bitcoin", "ftp", "ftps", "geo", "http", "https", "im", "irc",
|
||||||
|
"ircs", "magnet", "mailto", "mms", "mx", "news", "nntp",
|
||||||
|
"openpgp4fpr", "sip", "sms", "smsto", "ssh", "tel", "url",
|
||||||
|
"webcal", "wtai", "xmpp",
|
||||||
|
] {
|
||||||
|
set.insert(scheme);
|
||||||
|
}
|
||||||
|
set
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn is_safe_url(url: &str, allowed_schemes: &HashSet<&str>, allow_data_uri: bool) -> bool {
|
||||||
|
let trimmed = url.trim();
|
||||||
|
if trimmed.is_empty() {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if let Some(colon_pos) = trimmed.find(':') {
|
||||||
|
let scheme = &trimmed[..colon_pos];
|
||||||
|
let scheme_lower = scheme.to_lowercase();
|
||||||
|
if allowed_schemes.contains(scheme_lower.as_str()) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
if scheme_lower == "data" {
|
||||||
|
return allow_data_uri;
|
||||||
|
}
|
||||||
|
if scheme_lower == "javascript" || scheme_lower == "vbscript" {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
if scheme.contains(|c: char| c.is_ascii_whitespace()) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if trimmed.starts_with('#') || trimmed.starts_with('/') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
struct SanitizerConfig {
|
||||||
|
allowed_tags: HashSet<&'static str>,
|
||||||
|
extra_generic_attrs: Vec<&'static str>,
|
||||||
|
extra_tag_attrs: Vec<(&'static str, Vec<&'static str>)>,
|
||||||
|
allowed_schemes: HashSet<&'static str>,
|
||||||
|
allow_data_uri: bool,
|
||||||
|
link_rel: Option<&'static str>,
|
||||||
|
remove_tags: HashSet<&'static str>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
fn sanitize(input: &str, config: &SanitizerConfig) -> String {
|
||||||
|
let allowed_tags = config.allowed_tags.clone();
|
||||||
|
let remove_tags = config.remove_tags.clone();
|
||||||
|
let generic_attrs: HashSet<&str> = config
|
||||||
|
.extra_generic_attrs
|
||||||
|
.iter()
|
||||||
|
.copied()
|
||||||
|
.chain(["lang", "title"])
|
||||||
|
.collect();
|
||||||
|
let tag_attrs_map: std::collections::HashMap<&str, HashSet<&str>> = {
|
||||||
|
let mut m = std::collections::HashMap::new();
|
||||||
|
let base = [
|
||||||
|
("a", vec!["href", "hreflang"]),
|
||||||
|
("bdo", vec!["dir"]),
|
||||||
|
("blockquote", vec!["cite"]),
|
||||||
|
("col", vec!["align", "char", "charoff", "span"]),
|
||||||
|
("colgroup", vec!["align", "char", "charoff", "span"]),
|
||||||
|
("del", vec!["cite", "datetime"]),
|
||||||
|
("hr", vec!["align", "size", "width"]),
|
||||||
|
("img", vec!["align", "alt", "height", "src", "width"]),
|
||||||
|
("ins", vec!["cite", "datetime"]),
|
||||||
|
("ol", vec!["start"]),
|
||||||
|
("q", vec!["cite"]),
|
||||||
|
("table", vec!["align", "char", "charoff", "summary"]),
|
||||||
|
("tbody", vec!["align", "char", "charoff"]),
|
||||||
|
("td", vec!["align", "char", "charoff", "colspan", "headers", "rowspan"]),
|
||||||
|
("tfoot", vec!["align", "char", "charoff"]),
|
||||||
|
("th", vec!["align", "char", "charoff", "colspan", "headers", "rowspan", "scope"]),
|
||||||
|
("thead", vec!["align", "char", "charoff"]),
|
||||||
|
("tr", vec!["align", "char", "charoff"]),
|
||||||
|
];
|
||||||
|
for (tag, attrs) in &base {
|
||||||
|
m.insert(*tag, attrs.iter().copied().collect());
|
||||||
|
}
|
||||||
|
for (tag, attrs) in &config.extra_tag_attrs {
|
||||||
|
m.entry(tag)
|
||||||
|
.or_insert_with(HashSet::new)
|
||||||
|
.extend(attrs.iter().copied());
|
||||||
|
}
|
||||||
|
m
|
||||||
|
};
|
||||||
|
let allowed_schemes = config.allowed_schemes.clone();
|
||||||
|
let allow_data_uri = config.allow_data_uri;
|
||||||
|
let link_rel = config.link_rel;
|
||||||
|
|
||||||
|
let element_handler = move |el: &mut lol_html::html_content::Element| {
|
||||||
|
let tag = el.tag_name().to_lowercase();
|
||||||
|
|
||||||
|
if remove_tags.contains(tag.as_str()) {
|
||||||
|
el.remove();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
if !allowed_tags.contains(tag.as_str()) {
|
||||||
|
el.remove_and_keep_content();
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
|
||||||
|
let allowed_for_tag: HashSet<&str> = {
|
||||||
|
let mut s = generic_attrs.clone();
|
||||||
|
if let Some(tag_specific) = tag_attrs_map.get(tag.as_str()) {
|
||||||
|
s.extend(tag_specific.iter().copied());
|
||||||
|
}
|
||||||
|
s
|
||||||
|
};
|
||||||
|
|
||||||
|
let attrs_to_remove: Vec<String> = el
|
||||||
|
.attributes()
|
||||||
|
.iter()
|
||||||
|
.filter_map(|attr| {
|
||||||
|
let name = attr.name();
|
||||||
|
let name_lower = name.to_lowercase();
|
||||||
|
if allowed_for_tag.contains(name_lower.as_str()) {
|
||||||
|
if name_lower == "href" || name_lower == "src" || name_lower == "cite" {
|
||||||
|
let val = attr.value();
|
||||||
|
if !is_safe_url(&val, &allowed_schemes, allow_data_uri) {
|
||||||
|
return Some(name);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
Some(name)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
for attr_name in attrs_to_remove {
|
||||||
|
el.remove_attribute(&attr_name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if link_rel.is_some() && tag == "a" {
|
||||||
|
if let Some(rel) = link_rel {
|
||||||
|
let existing = el.get_attribute("rel").unwrap_or_default();
|
||||||
|
if existing != rel {
|
||||||
|
el.set_attribute("rel", rel).ok();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
|
||||||
|
lol_html::rewrite_str(
|
||||||
|
input,
|
||||||
|
lol_html::RewriteStrSettings {
|
||||||
|
element_content_handlers: vec![lol_html::element!("*", element_handler)],
|
||||||
|
document_content_handlers: vec![lol_html::doc_comments!(|c| {
|
||||||
|
c.remove();
|
||||||
|
Ok(())
|
||||||
|
})],
|
||||||
|
..lol_html::RewriteStrSettings::new()
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub fn clean_html(input: &str) -> String {
|
||||||
|
let config = SanitizerConfig {
|
||||||
|
allowed_tags: default_allowed_tags(),
|
||||||
|
extra_generic_attrs: vec![
|
||||||
|
"class",
|
||||||
|
"aria-hidden",
|
||||||
|
"aria-label",
|
||||||
|
"id",
|
||||||
|
"role",
|
||||||
|
"accesskey",
|
||||||
|
"title",
|
||||||
|
],
|
||||||
|
extra_tag_attrs: vec![
|
||||||
|
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||||
|
("span", vec!["class"]),
|
||||||
|
("h1", vec!["id", "class"]),
|
||||||
|
("h2", vec!["id", "class"]),
|
||||||
|
("h3", vec!["id", "class"]),
|
||||||
|
("h4", vec!["id", "class"]),
|
||||||
|
("h5", vec!["id", "class"]),
|
||||||
|
("h6", vec!["id", "class"]),
|
||||||
|
],
|
||||||
|
allowed_schemes: default_allowed_schemes(),
|
||||||
|
allow_data_uri: true,
|
||||||
|
link_rel: Some("noopener noreferrer"),
|
||||||
|
remove_tags: clean_content_tags(),
|
||||||
|
};
|
||||||
|
sanitize(input, &config)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(feature = "server")]
|
||||||
|
pub fn clean_comment_html(input: &str) -> String {
|
||||||
|
let mut tags = default_allowed_tags();
|
||||||
|
tags.remove("img");
|
||||||
|
tags.remove("details");
|
||||||
|
tags.remove("summary");
|
||||||
|
|
||||||
|
let config = SanitizerConfig {
|
||||||
|
allowed_tags: tags,
|
||||||
|
extra_generic_attrs: vec![
|
||||||
|
"class",
|
||||||
|
"title",
|
||||||
|
"aria-hidden",
|
||||||
|
"aria-label",
|
||||||
|
"role",
|
||||||
|
"accesskey",
|
||||||
|
],
|
||||||
|
extra_tag_attrs: vec![
|
||||||
|
("a", vec!["class", "aria-hidden", "aria-label"]),
|
||||||
|
("span", vec!["class"]),
|
||||||
|
],
|
||||||
|
allowed_schemes: default_allowed_schemes(),
|
||||||
|
allow_data_uri: false,
|
||||||
|
link_rel: Some("nofollow noopener"),
|
||||||
|
remove_tags: clean_content_tags(),
|
||||||
|
};
|
||||||
|
sanitize(input, &config)
|
||||||
|
}
|
||||||
@ -4,7 +4,7 @@ use dioxus::prelude::*;
|
|||||||
pub fn SkeletonBox(class: &'static str, style: Option<&'static str>) -> Element {
|
pub fn SkeletonBox(class: &'static str, style: Option<&'static str>) -> Element {
|
||||||
rsx! {
|
rsx! {
|
||||||
div {
|
div {
|
||||||
class: "bg-paper-tertiary/30 animate-pulse {class}",
|
class: "bg-paper-tertiary/30 dark:bg-[#5a5a62] animate-pulse {class}",
|
||||||
style: style.unwrap_or(""),
|
style: style.unwrap_or(""),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,7 @@ pub fn WriteSkeleton() -> Element {
|
|||||||
}
|
}
|
||||||
|
|
||||||
div { class: "flex-1 min-h-0 flex flex-col my-4",
|
div { class: "flex-1 min-h-0 flex flex-col my-4",
|
||||||
div { class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-[var(--color-paper-entry)] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none space-y-4 p-4",
|
div { class: "flex-1 min-h-0 w-full border border-[var(--color-paper-border)] rounded-xl overflow-hidden bg-white dark:bg-[#2e2e33] shadow-[0_2px_8px_rgba(0,0,0,0.04)] dark:shadow-none space-y-4 p-4",
|
||||||
div { class: "flex gap-2 pb-3 border-b border-[var(--color-paper-border)]",
|
div { class: "flex gap-2 pb-3 border-b border-[var(--color-paper-border)]",
|
||||||
for _ in 0..8 {
|
for _ in 0..8 {
|
||||||
SkeletonBox { class: "w-8 h-8 rounded" }
|
SkeletonBox { class: "w-8 h-8 rounded" }
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use dioxus::router::components::Link;
|
|||||||
use crate::api::posts::list_posts;
|
use crate::api::posts::list_posts;
|
||||||
#[cfg(target_arch = "wasm32")]
|
#[cfg(target_arch = "wasm32")]
|
||||||
use crate::api::posts::PostListResponse;
|
use crate::api::posts::PostListResponse;
|
||||||
use crate::api::posts::{delete_post, CreatePostResponse};
|
use crate::api::posts::{delete_post, rebuild_content_html, CreatePostResponse, RebuildResult};
|
||||||
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
use crate::components::skeletons::delayed_skeleton::DelayedSkeleton;
|
||||||
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
use crate::components::skeletons::posts_skeleton::PostsSkeleton;
|
||||||
use crate::models::post::Post;
|
use crate::models::post::Post;
|
||||||
@ -25,6 +25,8 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
let mut total = use_signal(|| 0_i64);
|
let mut total = use_signal(|| 0_i64);
|
||||||
let mut loading = use_signal(|| true);
|
let mut loading = use_signal(|| true);
|
||||||
let mut deleting = use_signal(|| None::<i32>);
|
let mut deleting = use_signal(|| None::<i32>);
|
||||||
|
let mut rebuilding = use_signal(|| false);
|
||||||
|
let mut rebuild_result = use_signal(|| Option::<String>::None);
|
||||||
|
|
||||||
use_effect(move || {
|
use_effect(move || {
|
||||||
let _ = current_page;
|
let _ = current_page;
|
||||||
@ -58,12 +60,60 @@ pub fn PostsPage(page: i32) -> Element {
|
|||||||
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
|
h1 { class: "text-2xl font-bold text-gray-900 dark:text-[#dadadb]",
|
||||||
"文章管理"
|
"文章管理"
|
||||||
}
|
}
|
||||||
|
div { class: "flex items-center gap-3",
|
||||||
|
div { class: "group relative",
|
||||||
|
button {
|
||||||
|
class: if rebuilding() {
|
||||||
|
"px-4 py-2 rounded-full text-sm font-medium cursor-not-allowed text-gray-400 dark:text-[#666] border border-gray-300 dark:border-[#444]"
|
||||||
|
} else {
|
||||||
|
"px-4 py-2 rounded-full text-sm font-medium cursor-pointer text-gray-700 dark:text-[#b0b0b1] border border-gray-300 dark:border-[#444] hover:border-gray-900 dark:hover:border-[#dadadb] hover:text-gray-900 dark:hover:text-[#dadadb] transition-all"
|
||||||
|
},
|
||||||
|
disabled: rebuilding(),
|
||||||
|
onclick: move |_| {
|
||||||
|
rebuilding.set(true);
|
||||||
|
rebuild_result.set(None);
|
||||||
|
spawn(async move {
|
||||||
|
match rebuild_content_html(false).await {
|
||||||
|
Ok(RebuildResult { rebuilt, failed, errors }) => {
|
||||||
|
if failed > 0 {
|
||||||
|
let mut msg = format!(
|
||||||
|
"已重建 {rebuilt} 篇,失败 {failed} 篇"
|
||||||
|
);
|
||||||
|
if let Some(first) = errors.first() {
|
||||||
|
msg.push_str(&format!("\n{first}"));
|
||||||
|
}
|
||||||
|
rebuild_result.set(Some(msg));
|
||||||
|
} else {
|
||||||
|
rebuild_result
|
||||||
|
.set(Some(format!("已重建 {rebuilt} 篇文章")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
rebuild_result.set(Some(format!("失败: {e}")));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rebuilding.set(false);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
if rebuilding() { "重建中..." } else { "重建内容" }
|
||||||
|
}
|
||||||
|
div { class: "pointer-events-none absolute top-full left-1/2 -translate-x-1/2 mt-2 px-3 py-1.5 text-xs font-medium whitespace-nowrap rounded-lg opacity-0 group-hover:opacity-100 transition-opacity duration-200 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-[#1a1a1a] shadow-lg z-50",
|
||||||
|
"重建 content_html 为空的文章渲染缓存"
|
||||||
|
}
|
||||||
|
}
|
||||||
Link {
|
Link {
|
||||||
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
class: "px-4 py-2 bg-gray-900 dark:bg-[#dadadb] text-white dark:text-gray-900 rounded-full text-sm font-medium hover:opacity-80 transition-opacity cursor-pointer",
|
||||||
to: Route::Write {},
|
to: Route::Write {},
|
||||||
"+ 写文章"
|
"+ 写文章"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(msg) = rebuild_result() {
|
||||||
|
div { class: "text-sm text-gray-600 dark:text-[#9b9c9d] px-1",
|
||||||
|
"{msg}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if loading() && posts().is_empty() {
|
if loading() && posts().is_empty() {
|
||||||
DelayedSkeleton { PostsSkeleton {} }
|
DelayedSkeleton { PostsSkeleton {} }
|
||||||
|
|||||||
@ -342,7 +342,7 @@ fn write_editor(post_id: Option<i32>) -> Element {
|
|||||||
rsx! {
|
rsx! {
|
||||||
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
|
div { class: "relative flex flex-col flex-1 min-h-0 overflow-hidden",
|
||||||
if loading() {
|
if loading() {
|
||||||
div { class: "absolute inset-0 z-10 bg-[var(--color-paper-theme)]",
|
div { class: "absolute inset-0 z-10 bg-white dark:bg-[#1d1e20]",
|
||||||
WriteSkeleton {}
|
WriteSkeleton {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user