max / makenotwork
50 files changed,
+3568 insertions,
-40 deletions
| @@ -45,6 +45,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 45 | 45 | checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" | |
| 46 | 46 | dependencies = [ | |
| 47 | 47 | "cfg-if", | |
| 48 | + | "getrandom 0.3.4", | |
| 48 | 49 | "once_cell", | |
| 49 | 50 | "version_check", | |
| 50 | 51 | "zerocopy", | |
| @@ -71,7 +72,7 @@ version = "4.1.2" | |||
| 71 | 72 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 72 | 73 | checksum = "17e913097e1a2124b46746c980134e8c954bc17a6a59bb3fde96f088d126dde6" | |
| 73 | 74 | dependencies = [ | |
| 74 | - | "cssparser", | |
| 75 | + | "cssparser 0.35.0", | |
| 75 | 76 | "html5ever", | |
| 76 | 77 | "maplit", | |
| 77 | 78 | "tendril", | |
| @@ -350,7 +351,7 @@ dependencies = [ | |||
| 350 | 351 | "rustc-hash 2.1.1", | |
| 351 | 352 | "serde", | |
| 352 | 353 | "serde_derive", | |
| 353 | - | "syn", | |
| 354 | + | "syn 2.0.117", | |
| 354 | 355 | ] | |
| 355 | 356 | ||
| 356 | 357 | [[package]] | |
| @@ -405,7 +406,7 @@ checksum = "965c2d33e53cb6b267e148a4cb0760bc01f4904c1cd4bb4002a085bb016d1490" | |||
| 405 | 406 | dependencies = [ | |
| 406 | 407 | "proc-macro2", | |
| 407 | 408 | "quote", | |
| 408 | - | "syn", | |
| 409 | + | "syn 2.0.117", | |
| 409 | 410 | "synstructure", | |
| 410 | 411 | ] | |
| 411 | 412 | ||
| @@ -417,7 +418,7 @@ checksum = "3109e49b1e4909e9db6515a30c633684d68cdeaa252f215214cb4fa1a5bfee2c" | |||
| 417 | 418 | dependencies = [ | |
| 418 | 419 | "proc-macro2", | |
| 419 | 420 | "quote", | |
| 420 | - | "syn", | |
| 421 | + | "syn 2.0.117", | |
| 421 | 422 | "synstructure", | |
| 422 | 423 | ] | |
| 423 | 424 | ||
| @@ -429,7 +430,7 @@ checksum = "7b18050c2cd6fe86c3a76584ef5e0baf286d038cda203eb6223df2cc413565f7" | |||
| 429 | 430 | dependencies = [ | |
| 430 | 431 | "proc-macro2", | |
| 431 | 432 | "quote", | |
| 432 | - | "syn", | |
| 433 | + | "syn 2.0.117", | |
| 433 | 434 | ] | |
| 434 | 435 | ||
| 435 | 436 | [[package]] | |
| @@ -461,7 +462,7 @@ checksum = "c7c24de15d275a1ecfd47a380fb4d5ec9bfe0933f309ed5e705b775596a3574d" | |||
| 461 | 462 | dependencies = [ | |
| 462 | 463 | "proc-macro2", | |
| 463 | 464 | "quote", | |
| 464 | - | "syn", | |
| 465 | + | "syn 2.0.117", | |
| 465 | 466 | ] | |
| 466 | 467 | ||
| 467 | 468 | [[package]] | |
| @@ -631,7 +632,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" | |||
| 631 | 632 | dependencies = [ | |
| 632 | 633 | "proc-macro2", | |
| 633 | 634 | "quote", | |
| 634 | - | "syn", | |
| 635 | + | "syn 2.0.117", | |
| 635 | 636 | ] | |
| 636 | 637 | ||
| 637 | 638 | [[package]] | |
| @@ -1084,7 +1085,7 @@ checksum = "8d7396fd9500589e62e460e987ecb671bad374934e55ec3b5f498cc7a8a8a7b7" | |||
| 1084 | 1085 | dependencies = [ | |
| 1085 | 1086 | "proc-macro2", | |
| 1086 | 1087 | "quote", | |
| 1087 | - | "syn", | |
| 1088 | + | "syn 2.0.117", | |
| 1088 | 1089 | ] | |
| 1089 | 1090 | ||
| 1090 | 1091 | [[package]] | |
| @@ -1224,7 +1225,7 @@ checksum = "604fde5e028fea851ce1d8570bbdc034bec850d157f7569d10f347d06808c05c" | |||
| 1224 | 1225 | dependencies = [ | |
| 1225 | 1226 | "proc-macro2", | |
| 1226 | 1227 | "quote", | |
| 1227 | - | "syn", | |
| 1228 | + | "syn 2.0.117", | |
| 1228 | 1229 | ] | |
| 1229 | 1230 | ||
| 1230 | 1231 | [[package]] | |
| @@ -1649,7 +1650,7 @@ dependencies = [ | |||
| 1649 | 1650 | "heck", | |
| 1650 | 1651 | "proc-macro2", | |
| 1651 | 1652 | "quote", | |
| 1652 | - | "syn", | |
| 1653 | + | "syn 2.0.117", | |
| 1653 | 1654 | ] | |
| 1654 | 1655 | ||
| 1655 | 1656 | [[package]] | |
| @@ -1735,6 +1736,26 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1735 | 1736 | checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c" | |
| 1736 | 1737 | ||
| 1737 | 1738 | [[package]] | |
| 1739 | + | name = "const-str" | |
| 1740 | + | version = "0.3.2" | |
| 1741 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1742 | + | checksum = "21077772762a1002bb421c3af42ac1725fa56066bfc53d9a55bb79905df2aaf3" | |
| 1743 | + | dependencies = [ | |
| 1744 | + | "const-str-proc-macro", | |
| 1745 | + | ] | |
| 1746 | + | ||
| 1747 | + | [[package]] | |
| 1748 | + | name = "const-str-proc-macro" | |
| 1749 | + | version = "0.3.2" | |
| 1750 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1751 | + | checksum = "5e1e0fdd2e5d3041e530e1b21158aeeef8b5d0e306bc5c1e3d6cf0930d10e25a" | |
| 1752 | + | dependencies = [ | |
| 1753 | + | "proc-macro2", | |
| 1754 | + | "quote", | |
| 1755 | + | "syn 1.0.109", | |
| 1756 | + | ] | |
| 1757 | + | ||
| 1758 | + | [[package]] | |
| 1738 | 1759 | name = "const_panic" | |
| 1739 | 1760 | version = "0.2.15" | |
| 1740 | 1761 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -1756,6 +1777,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 1756 | 1777 | checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b" | |
| 1757 | 1778 | ||
| 1758 | 1779 | [[package]] | |
| 1780 | + | name = "convert_case" | |
| 1781 | + | version = "0.6.0" | |
| 1782 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1783 | + | checksum = "ec182b0ca2f35d8fc196cf3404988fd8b8c739a4d270ff118a398feb0cbec1ca" | |
| 1784 | + | dependencies = [ | |
| 1785 | + | "unicode-segmentation", | |
| 1786 | + | ] | |
| 1787 | + | ||
| 1788 | + | [[package]] | |
| 1759 | 1789 | name = "cookie" | |
| 1760 | 1790 | version = "0.18.1" | |
| 1761 | 1791 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -2136,6 +2166,19 @@ dependencies = [ | |||
| 2136 | 2166 | ||
| 2137 | 2167 | [[package]] | |
| 2138 | 2168 | name = "cssparser" | |
| 2169 | + | version = "0.33.0" | |
| 2170 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2171 | + | checksum = "9be934d936a0fbed5bcdc01042b770de1398bf79d0e192f49fa7faea0e99281e" | |
| 2172 | + | dependencies = [ | |
| 2173 | + | "cssparser-macros", | |
| 2174 | + | "dtoa-short", | |
| 2175 | + | "itoa", | |
| 2176 | + | "phf", | |
| 2177 | + | "smallvec", | |
| 2178 | + | ] | |
| 2179 | + | ||
| 2180 | + | [[package]] | |
| 2181 | + | name = "cssparser" | |
| 2139 | 2182 | version = "0.35.0" | |
| 2140 | 2183 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2141 | 2184 | checksum = "4e901edd733a1472f944a45116df3f846f54d37e67e68640ac8bb69689aca2aa" | |
| @@ -2148,13 +2191,22 @@ dependencies = [ | |||
| 2148 | 2191 | ] | |
| 2149 | 2192 | ||
| 2150 | 2193 | [[package]] | |
| 2194 | + | name = "cssparser-color" | |
| 2195 | + | version = "0.1.0" | |
| 2196 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2197 | + | checksum = "556c099a61d85989d7af52b692e35a8d68a57e7df8c6d07563dc0778b3960c9f" | |
| 2198 | + | dependencies = [ | |
| 2199 | + | "cssparser 0.33.0", | |
| 2200 | + | ] | |
| 2201 | + | ||
| 2202 | + | [[package]] | |
| 2151 | 2203 | name = "cssparser-macros" | |
| 2152 | 2204 | version = "0.6.1" | |
| 2153 | 2205 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2154 | 2206 | checksum = "13b588ba4ac1a99f7f2964d24b3d896ddc6bf847ee3855dbd4366f058cfcd331" | |
| 2155 | 2207 | dependencies = [ | |
| 2156 | 2208 | "quote", | |
| 2157 | - | "syn", | |
| 2209 | + | "syn 2.0.117", | |
| 2158 | 2210 | ] | |
| 2159 | 2211 | ||
| 2160 | 2212 | [[package]] | |
| @@ -2210,7 +2262,7 @@ checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3" | |||
| 2210 | 2262 | dependencies = [ | |
| 2211 | 2263 | "proc-macro2", | |
| 2212 | 2264 | "quote", | |
| 2213 | - | "syn", | |
| 2265 | + | "syn 2.0.117", | |
| 2214 | 2266 | ] | |
| 2215 | 2267 | ||
| 2216 | 2268 | [[package]] | |
| @@ -2240,7 +2292,7 @@ dependencies = [ | |||
| 2240 | 2292 | "proc-macro2", | |
| 2241 | 2293 | "quote", | |
| 2242 | 2294 | "strsim", | |
| 2243 | - | "syn", | |
| 2295 | + | "syn 2.0.117", | |
| 2244 | 2296 | ] | |
| 2245 | 2297 | ||
| 2246 | 2298 | [[package]] | |
| @@ -2251,7 +2303,7 @@ checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" | |||
| 2251 | 2303 | dependencies = [ | |
| 2252 | 2304 | "darling_core", | |
| 2253 | 2305 | "quote", | |
| 2254 | - | "syn", | |
| 2306 | + | "syn 2.0.117", | |
| 2255 | 2307 | ] | |
| 2256 | 2308 | ||
| 2257 | 2309 | [[package]] | |
| @@ -2357,7 +2409,7 @@ checksum = "8034092389675178f570469e6c3b0465d3d30b4505c294a6550db47f3c17ad18" | |||
| 2357 | 2409 | dependencies = [ | |
| 2358 | 2410 | "proc-macro2", | |
| 2359 | 2411 | "quote", | |
| 2360 | - | "syn", | |
| 2412 | + | "syn 2.0.117", | |
| 2361 | 2413 | ] | |
| 2362 | 2414 | ||
| 2363 | 2415 | [[package]] | |
| @@ -2378,7 +2430,7 @@ checksum = "1e567bd82dcff979e4b03460c307b3cdc9e96fde3d73bed1496d2bc75d9dd62a" | |||
| 2378 | 2430 | dependencies = [ | |
| 2379 | 2431 | "proc-macro2", | |
| 2380 | 2432 | "quote", | |
| 2381 | - | "syn", | |
| 2433 | + | "syn 2.0.117", | |
| 2382 | 2434 | ] | |
| 2383 | 2435 | ||
| 2384 | 2436 | [[package]] | |
| @@ -2462,7 +2514,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" | |||
| 2462 | 2514 | dependencies = [ | |
| 2463 | 2515 | "proc-macro2", | |
| 2464 | 2516 | "quote", | |
| 2465 | - | "syn", | |
| 2517 | + | "syn 2.0.117", | |
| 2466 | 2518 | ] | |
| 2467 | 2519 | ||
| 2468 | 2520 | [[package]] | |
| @@ -2970,7 +3022,7 @@ checksum = "e835b70203e41293343137df5c0664546da5745f82ec9b84d40be8336958447b" | |||
| 2970 | 3022 | dependencies = [ | |
| 2971 | 3023 | "proc-macro2", | |
| 2972 | 3024 | "quote", | |
| 2973 | - | "syn", | |
| 3025 | + | "syn 2.0.117", | |
| 2974 | 3026 | ] | |
| 2975 | 3027 | ||
| 2976 | 3028 | [[package]] | |
| @@ -3865,6 +3917,15 @@ checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" | |||
| 3865 | 3917 | ||
| 3866 | 3918 | [[package]] | |
| 3867 | 3919 | name = "itertools" | |
| 3920 | + | version = "0.10.5" | |
| 3921 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3922 | + | checksum = "b0fd2260e829bddf4cb6ea802289de2f86d6a7a690192fbe91b3f46e0f2c8473" | |
| 3923 | + | dependencies = [ | |
| 3924 | + | "either", | |
| 3925 | + | ] | |
| 3926 | + | ||
| 3927 | + | [[package]] | |
| 3928 | + | name = "itertools" | |
| 3868 | 3929 | version = "0.13.0" | |
| 3869 | 3930 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3870 | 3931 | checksum = "413ee7dfc52ee1a4949ceeb7dbc8a33f2d6c088194d9f922fb8318faf1f01186" | |
| @@ -3908,7 +3969,7 @@ checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" | |||
| 3908 | 3969 | dependencies = [ | |
| 3909 | 3970 | "proc-macro2", | |
| 3910 | 3971 | "quote", | |
| 3911 | - | "syn", | |
| 3972 | + | "syn 2.0.117", | |
| 3912 | 3973 | ] | |
| 3913 | 3974 | ||
| 3914 | 3975 | [[package]] | |
| @@ -4070,6 +4131,41 @@ dependencies = [ | |||
| 4070 | 4131 | ] | |
| 4071 | 4132 | ||
| 4072 | 4133 | [[package]] | |
| 4134 | + | name = "lightningcss" | |
| 4135 | + | version = "1.0.0-alpha.71" | |
| 4136 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4137 | + | checksum = "cb6314c2f0590ac93c86099b98bb7ba8abcf759bfd89604ffca906472bb54937" | |
| 4138 | + | dependencies = [ | |
| 4139 | + | "ahash", | |
| 4140 | + | "bitflags 2.11.0", | |
| 4141 | + | "const-str", | |
| 4142 | + | "cssparser 0.33.0", | |
| 4143 | + | "cssparser-color", | |
| 4144 | + | "data-encoding", | |
| 4145 | + | "getrandom 0.3.4", | |
| 4146 | + | "indexmap", | |
| 4147 | + | "itertools 0.10.5", | |
| 4148 | + | "lazy_static", | |
| 4149 | + | "lightningcss-derive", | |
| 4150 | + | "parcel_selectors", | |
| 4151 | + | "pastey", | |
| 4152 | + | "pathdiff", | |
| 4153 | + | "smallvec", | |
| 4154 | + | ] | |
| 4155 | + | ||
| 4156 | + | [[package]] | |
| 4157 | + | name = "lightningcss-derive" | |
| 4158 | + | version = "1.0.0-alpha.43" | |
| 4159 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4160 | + | checksum = "84c12744d1279367caed41739ef094c325d53fb0ffcd4f9b84a368796f870252" | |
| 4161 | + | dependencies = [ | |
| 4162 | + | "convert_case", | |
| 4163 | + | "proc-macro2", | |
| 4164 | + | "quote", | |
| 4165 | + | "syn 1.0.109", | |
| 4166 | + | ] | |
| 4167 | + | ||
| 4168 | + | [[package]] | |
| 4073 | 4169 | name = "linux-raw-sys" | |
| 4074 | 4170 | version = "0.4.15" | |
| 4075 | 4171 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4131,7 +4227,7 @@ dependencies = [ | |||
| 4131 | 4227 | "quote", | |
| 4132 | 4228 | "regex-syntax", | |
| 4133 | 4229 | "rustc_version", | |
| 4134 | - | "syn", | |
| 4230 | + | "syn 2.0.117", | |
| 4135 | 4231 | ] | |
| 4136 | 4232 | ||
| 4137 | 4233 | [[package]] | |
| @@ -4197,6 +4293,7 @@ dependencies = [ | |||
| 4197 | 4293 | name = "makenotwork" | |
| 4198 | 4294 | version = "0.10.1" | |
| 4199 | 4295 | dependencies = [ | |
| 4296 | + | "ammonia", | |
| 4200 | 4297 | "anyhow", | |
| 4201 | 4298 | "apple-codesign", | |
| 4202 | 4299 | "argon2", | |
| @@ -4236,6 +4333,7 @@ dependencies = [ | |||
| 4236 | 4333 | "include_dir", | |
| 4237 | 4334 | "infer", | |
| 4238 | 4335 | "jsonwebtoken", | |
| 4336 | + | "lightningcss", | |
| 4239 | 4337 | "log", | |
| 4240 | 4338 | "memmap2", | |
| 4241 | 4339 | "metrics", | |
| @@ -4311,7 +4409,7 @@ checksum = "ac84fd3f360fcc43dc5f5d186f02a94192761a080e8bc58621ad4d12296a58cf" | |||
| 4311 | 4409 | dependencies = [ | |
| 4312 | 4410 | "proc-macro2", | |
| 4313 | 4411 | "quote", | |
| 4314 | - | "syn", | |
| 4412 | + | "syn 2.0.117", | |
| 4315 | 4413 | ] | |
| 4316 | 4414 | ||
| 4317 | 4415 | [[package]] | |
| @@ -4446,7 +4544,7 @@ checksum = "2a8df435db5df1dd82a74f77e3c3addf6ab7665079c31e222a64f34f7475d87e" | |||
| 4446 | 4544 | dependencies = [ | |
| 4447 | 4545 | "proc-macro2", | |
| 4448 | 4546 | "quote", | |
| 4449 | - | "syn", | |
| 4547 | + | "syn 2.0.117", | |
| 4450 | 4548 | ] | |
| 4451 | 4549 | ||
| 4452 | 4550 | [[package]] | |
| @@ -4466,7 +4564,7 @@ checksum = "bd2209fff77f705b00c737016a48e73733d7fbccb8b007194db148f03561fb70" | |||
| 4466 | 4564 | dependencies = [ | |
| 4467 | 4565 | "proc-macro2", | |
| 4468 | 4566 | "quote", | |
| 4469 | - | "syn", | |
| 4567 | + | "syn 2.0.117", | |
| 4470 | 4568 | ] | |
| 4471 | 4569 | ||
| 4472 | 4570 | [[package]] | |
| @@ -4626,7 +4724,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" | |||
| 4626 | 4724 | dependencies = [ | |
| 4627 | 4725 | "proc-macro2", | |
| 4628 | 4726 | "quote", | |
| 4629 | - | "syn", | |
| 4727 | + | "syn 2.0.117", | |
| 4630 | 4728 | ] | |
| 4631 | 4729 | ||
| 4632 | 4730 | [[package]] | |
| @@ -4765,7 +4863,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" | |||
| 4765 | 4863 | dependencies = [ | |
| 4766 | 4864 | "proc-macro2", | |
| 4767 | 4865 | "quote", | |
| 4768 | - | "syn", | |
| 4866 | + | "syn 2.0.117", | |
| 4769 | 4867 | ] | |
| 4770 | 4868 | ||
| 4771 | 4869 | [[package]] | |
| @@ -4867,6 +4965,22 @@ dependencies = [ | |||
| 4867 | 4965 | ] | |
| 4868 | 4966 | ||
| 4869 | 4967 | [[package]] | |
| 4968 | + | name = "parcel_selectors" | |
| 4969 | + | version = "0.28.2" | |
| 4970 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4971 | + | checksum = "54fd03f1ad26cb6b3ec1b7414fa78a3bd639e7dbb421b1a60513c96ce886a196" | |
| 4972 | + | dependencies = [ | |
| 4973 | + | "bitflags 2.11.0", | |
| 4974 | + | "cssparser 0.33.0", | |
| 4975 | + | "log", | |
| 4976 | + | "phf", | |
| 4977 | + | "phf_codegen", | |
| 4978 | + | "precomputed-hash", | |
| 4979 | + | "rustc-hash 2.1.1", | |
| 4980 | + | "smallvec", | |
| 4981 | + | ] | |
| 4982 | + | ||
| 4983 | + | [[package]] | |
| 4870 | 4984 | name = "parking" | |
| 4871 | 4985 | version = "2.2.1" | |
| 4872 | 4986 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4919,6 +5033,12 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 4919 | 5033 | checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec" | |
| 4920 | 5034 | ||
| 4921 | 5035 | [[package]] | |
| 5036 | + | name = "pathdiff" | |
| 5037 | + | version = "0.2.3" | |
| 5038 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5039 | + | checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" | |
| 5040 | + | ||
| 5041 | + | [[package]] | |
| 4922 | 5042 | name = "pbkdf2" | |
| 4923 | 5043 | version = "0.12.2" | |
| 4924 | 5044 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| @@ -4948,7 +5068,7 @@ dependencies = [ | |||
| 4948 | 5068 | "proc-macro2", | |
| 4949 | 5069 | "proc-macro2-diagnostics", | |
| 4950 | 5070 | "quote", | |
| 4951 | - | "syn", | |
| 5071 | + | "syn 2.0.117", | |
| 4952 | 5072 | ] | |
| 4953 | 5073 | ||
| 4954 | 5074 | [[package]] | |
| @@ -5016,7 +5136,7 @@ dependencies = [ | |||
| 5016 | 5136 | "phf_shared", | |
| 5017 | 5137 | "proc-macro2", | |
| 5018 | 5138 | "quote", | |
| 5019 | - | "syn", | |
| 5139 | + | "syn 2.0.117", | |
| 5020 | 5140 | ] | |
| 5021 | 5141 | ||
| 5022 | 5142 | [[package]] | |
| @@ -5045,7 +5165,7 @@ checksum = "d9b20ed30f105399776b9c883e68e536ef602a16ae6f596d2c473591d6ad64c6" | |||
| 5045 | 5165 | dependencies = [ | |
| 5046 | 5166 | "proc-macro2", | |
| 5047 | 5167 | "quote", | |
| 5048 | - | "syn", | |
| 5168 | + | "syn 2.0.117", | |
| 5049 | 5169 | ] | |
| 5050 | 5170 | ||
| 5051 | 5171 | [[package]] | |
| @@ -5218,7 +5338,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" | |||
| 5218 | 5338 | checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" | |
| 5219 | 5339 | dependencies = [ | |
| 5220 | 5340 | "proc-macro2", | |
| 5221 | - | "syn", | |
| 5341 | + | "syn 2.0.117", | |
| 5222 | 5342 | ] | |
| 5223 | 5343 | ||
| 5224 | 5344 | [[package]] | |
| @@ -5247,7 +5367,7 @@ checksum = "af066a9c399a26e020ada66a034357a868728e72cd426f3adcd35f80d88d88c8" | |||
| 5247 | 5367 | dependencies = [ | |
| 5248 | 5368 | "proc-macro2", | |
| 5249 | 5369 | "quote", | |
| 5250 | - | "syn", | |
| 5370 | + | "syn 2.0.117", | |
| 5251 | 5371 | "version_check", | |
| 5252 | 5372 | "yansi", | |
| 5253 | 5373 | ] | |
| @@ -5386,7 +5506,7 @@ checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" | |||
| 5386 | 5506 | dependencies = [ | |
| 5387 | 5507 | "proc-macro2", | |
| 5388 | 5508 | "quote", | |
| 5389 | - | "syn", | |
| 5509 | + | "syn 2.0.117", | |
| 5390 | 5510 | ] | |
| 5391 | 5511 | ||
| 5392 | 5512 | [[package]] | |
| @@ -5636,7 +5756,7 @@ dependencies = [ | |||
| 5636 | 5756 | "proc-macro2", | |
| 5637 | 5757 | "quote", | |
| 5638 | 5758 | "rayon", | |
| 5639 | - | "syn", | |
| 5759 | + | "syn 2.0.117", | |
| 5640 | 5760 | "uuid", | |
| 5641 | 5761 | ] | |
| 5642 | 5762 | ||
| @@ -6151,7 +6271,7 @@ checksum = "1783eabc414609e28a5ba76aee5ddd52199f7107a0b24c2e9746a1ecc34a683d" | |||
| 6151 | 6271 | dependencies = [ | |
| 6152 | 6272 | "proc-macro2", | |
| 6153 | 6273 | "quote", | |
| 6154 | - | "syn", | |
| 6274 | + | "syn 2.0.117", | |
| 6155 | 6275 | ] | |
| 6156 | 6276 | ||
| 6157 | 6277 | [[package]] | |
| @@ -6162,7 +6282,7 @@ checksum = "ed76efe62313ab6610570951494bdaa81568026e0318eaa55f167de70eeea67d" | |||
| 6162 | 6282 | dependencies = [ | |
| 6163 | 6283 | "proc-macro2", | |
| 6164 | 6284 | "quote", | |
| 6165 | - | "syn", | |
| 6285 | + | "syn 2.0.117", | |
| 6166 | 6286 | ] | |
| 6167 | 6287 | ||
| 6168 | 6288 | [[package]] | |
| @@ -6304,7 +6424,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" | |||
| 6304 | 6424 | dependencies = [ | |
| 6305 | 6425 | "proc-macro2", | |
| 6306 | 6426 | "quote", | |
| 6307 | - | "syn", | |
| 6427 | + | "syn 2.0.117", | |
| 6308 | 6428 | ] | |
| 6309 | 6429 | ||
| 6310 | 6430 | [[package]] | |
| @@ -6581,7 +6701,7 @@ dependencies = [ | |||
| 6581 | 6701 | "heck", | |
| 6582 | 6702 | "proc-macro2", | |
| 6583 | 6703 | "quote", | |
| 6584 | - | "syn", | |
| 6704 | + | "syn 2.0.117", | |
| 6585 | 6705 | ] | |
| 6586 | 6706 | ||
| 6587 | 6707 | [[package]] | |
| @@ -6720,7 +6840,7 @@ dependencies = [ | |||
| 6720 | 6840 | "quote", | |
| 6721 | 6841 | "sqlx-core", | |
| 6722 | 6842 | "sqlx-macros-core", | |
| 6723 | - | "syn", | |
| 6843 | + | "syn 2.0.117", | |
| 6724 | 6844 | ] | |
| 6725 | 6845 | ||
| 6726 | 6846 | [[package]] | |
| @@ -6743,7 +6863,7 @@ dependencies = [ | |||
| 6743 | 6863 | "sqlx-mysql", | |
| 6744 | 6864 | "sqlx-postgres", | |
| 6745 | 6865 | "sqlx-sqlite", | |
| 6746 | - | "syn", | |
| 6866 | + | "syn 2.0.117", | |
| 6747 | 6867 | "tokio", | |
| 6748 | 6868 | "url", | |
| 6749 | 6869 | ] | |
| @@ -6923,7 +7043,7 @@ dependencies = [ | |||
| 6923 | 7043 | "heck", | |
| 6924 | 7044 | "proc-macro2", | |
| 6925 | 7045 | "quote", | |
| 6926 | - | "syn", | |
| 7046 | + | "syn 2.0.117", | |
| 6927 | 7047 | ] | |
| 6928 | 7048 | ||
| 6929 | 7049 | [[package]] | |
| @@ -6934,6 +7054,17 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" | |||
| 6934 | 7054 | ||
| 6935 | 7055 | [[package]] |
Lines truncated
| @@ -155,6 +155,8 @@ authenticode = { version = "0.5.0", features = ["std", "object"] } | |||
| 155 | 155 | x509-cert = "0.2.5" | |
| 156 | 156 | const-oid = { version = "0.9", features = ["db"] } | |
| 157 | 157 | object = { version = "0.37", features = ["pe"] } | |
| 158 | + | ammonia = "4" | |
| 159 | + | lightningcss = { version = "1.0.0-alpha.71", default-features = false, features = ["visitor"] } | |
| 158 | 160 | ||
| 159 | 161 | [[bin]] | |
| 160 | 162 | name = "mnw-admin" |
| @@ -0,0 +1,49 @@ | |||
| 1 | + | -- Custom Pages (Phase 1 foundation): creators author their own HTML + CSS for | |
| 2 | + | -- their public profile and each project page. v1 covers users and projects | |
| 3 | + | -- only; item pages inherit their parent project's styling at render time, so | |
| 4 | + | -- items get no columns here. See plans/custom-pages.md. | |
| 5 | + | -- | |
| 6 | + | -- These columns hold the user's original source (shown in the editor). The | |
| 7 | + | -- sanitized render output is regenerated on save and cached separately; Phase 1 | |
| 8 | + | -- introduces only the source columns. An empty string means "no customization, | |
| 9 | + | -- render the platform default" -- there is no separate enabled flag. The | |
| 10 | + | -- octet_length caps are defensive backstops behind the app-side size limits | |
| 11 | + | -- (16KB HTML, 32KB CSS). | |
| 12 | + | ||
| 13 | + | ALTER TABLE users | |
| 14 | + | ADD COLUMN custom_html TEXT NOT NULL DEFAULT '' | |
| 15 | + | CHECK (octet_length(custom_html) <= 16384), | |
| 16 | + | ADD COLUMN custom_css TEXT NOT NULL DEFAULT '' | |
| 17 | + | CHECK (octet_length(custom_css) <= 32768), | |
| 18 | + | ADD COLUMN custom_pages_updated_at TIMESTAMPTZ, | |
| 19 | + | -- Per-user moderation kill switch: while true the editor is read-only. | |
| 20 | + | ADD COLUMN custom_pages_locked BOOLEAN NOT NULL DEFAULT FALSE; | |
| 21 | + | ||
| 22 | + | ALTER TABLE projects | |
| 23 | + | ADD COLUMN custom_html TEXT NOT NULL DEFAULT '' | |
| 24 | + | CHECK (octet_length(custom_html) <= 16384), | |
| 25 | + | ADD COLUMN custom_css TEXT NOT NULL DEFAULT '' | |
| 26 | + | CHECK (octet_length(custom_css) <= 32768), | |
| 27 | + | ADD COLUMN custom_pages_updated_at TIMESTAMPTZ; | |
| 28 | + | ||
| 29 | + | -- Drafts let a creator experiment without touching the live page. One draft per | |
| 30 | + | -- (owner, page kind, page) -- the editor upserts on this key. Drafts are | |
| 31 | + | -- ephemeral (not git-backed); a scheduled job clears those older than 30 days | |
| 32 | + | -- (by created_at). page_kind is 'user' or 'project'; page_id is the user or | |
| 33 | + | -- project id. owner_id is always the editing user (the project owner for | |
| 34 | + | -- project drafts) so a per-user cascade cleans everything on account deletion. | |
| 35 | + | CREATE TABLE custom_page_drafts ( | |
| 36 | + | id UUID PRIMARY KEY DEFAULT gen_random_uuid(), | |
| 37 | + | owner_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, | |
| 38 | + | page_kind TEXT NOT NULL CHECK (page_kind IN ('user', 'project')), | |
| 39 | + | page_id UUID NOT NULL, | |
| 40 | + | custom_html TEXT NOT NULL DEFAULT '' | |
| 41 | + | CHECK (octet_length(custom_html) <= 16384), | |
| 42 | + | custom_css TEXT NOT NULL DEFAULT '' | |
| 43 | + | CHECK (octet_length(custom_css) <= 32768), | |
| 44 | + | created_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 45 | + | updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), | |
| 46 | + | UNIQUE (owner_id, page_kind, page_id) | |
| 47 | + | ); | |
| 48 | + | ||
| 49 | + | CREATE INDEX idx_custom_page_drafts_created_at ON custom_page_drafts (created_at); |
| @@ -0,0 +1,116 @@ | |||
| 1 | + | # Custom Pages | |
| 2 | + | ||
| 3 | + | Write your own HTML and CSS for your profile and project pages. Custom pages are served from `u.makenot.work`, so your styling never collides with the rest of the platform. | |
| 4 | + | ||
| 5 | + | The one rule: a custom page references only Makenot.work. No external scripts, no third-party images or fonts, no tracking. Everything you use lives on the platform. This keeps your page private for your visitors, free of surprise third-party loads, and working for as long as Makenot.work is around. | |
| 6 | + | ||
| 7 | + | ## What you can customize | |
| 8 | + | ||
| 9 | + | - **Your profile** (`u.makenot.work/yourname`) — full HTML and CSS. | |
| 10 | + | - **Each project** (`u.makenot.work/yourname/project-slug`) — full HTML and CSS. | |
| 11 | + | - **Item pages** inherit their parent project's CSS automatically. There is no separate item editor: style your items by styling the project. A project's buy button, file list, and price slots carry its look onto every item. | |
| 12 | + | ||
| 13 | + | ## The editor | |
| 14 | + | ||
| 15 | + | Open it from **Settings > Profile > Custom page**, or a project's **Settings > Custom page**. | |
| 16 | + | ||
| 17 | + | - Two editors: one for HTML, one for CSS. | |
| 18 | + | - A live preview updates as you type. | |
| 19 | + | - A **Blocked references** panel lists anything the sanitizer stripped, with the reason. This is how you learn the rules: if a link points off-platform, it shows up here and is dropped. | |
| 20 | + | - **Save and publish** makes your changes live. **Reset to default** clears everything back to the standard page. | |
| 21 | + | ||
| 22 | + | Edits autosave to a private draft while you work, so the preview reflects your changes without touching the live page until you publish. | |
| 23 | + | ||
| 24 | + | ## What HTML is allowed | |
| 25 | + | ||
| 26 | + | Structure and text: `div`, `section`, `article`, `header`, `footer`, `nav`, `main`, `aside`, `h1`–`h6`, `p`, `ul`, `ol`, `li`, `dl`, `blockquote`, `pre`, `code`, `table` and friends, plus inline tags like `strong`, `em`, `a`, `span`, `mark`, `time`, `abbr`. | |
| 27 | + | ||
| 28 | + | Media: `img`, `picture`, `source`, `video`, `audio`, `track`, `figure`, `figcaption`. | |
| 29 | + | ||
| 30 | + | Not allowed, and removed on save: `script`, `style` (put CSS in the CSS editor), `iframe`, `object`, `embed`, `form` and inputs, and the inline `style` attribute. There is no JavaScript, by design. | |
| 31 | + | ||
| 32 | + | Links and media must point to Makenot.work. External `href`s and `src`s are stripped and listed in the blocked panel. | |
| 33 | + | ||
| 34 | + | ## What CSS is allowed | |
| 35 | + | ||
| 36 | + | Almost everything. Your CSS is automatically scoped to your page's canvas, so you cannot accidentally restyle the platform chrome around it. A few notes: | |
| 37 | + | ||
| 38 | + | - `@import` is not allowed (it would pull in off-platform CSS). Put everything in the one editor. | |
| 39 | + | - `url(...)` must resolve to Makenot.work — your own uploaded files, or the built-in assets below. | |
| 40 | + | - `@font-face` works, but its `src` must be on-platform. | |
| 41 | + | - Selectors like `html`, `body`, and `:root` are scoped to your canvas, so put CSS variables on a wrapper element rather than `:root`. | |
| 42 | + | - Fast strobing animations are capped; a reduced-motion fallback is added automatically. | |
| 43 | + | ||
| 44 | + | ## System slots | |
| 45 | + | ||
| 46 | + | Project and item pages include a few elements you can style but not remove: the buy/subscribe block (`.mnw-buy`), the file list (`.mnw-files`), and the item block (`.mnw-item`). Style them to match your page. Rules that try to hide them (for example `display: none`) are dropped — visitors always have a way to buy and a way to report. | |
| 47 | + | ||
| 48 | + | ## Using your own files | |
| 49 | + | ||
| 50 | + | Any file you've uploaded can be referenced by its on-platform URL — as an `<img>`, a `<video>`/`<audio>` source, or a CSS `background-image`. The buy button still appears for paid items; the page is your storefront, not a way around checkout. | |
| 51 | + | ||
| 52 | + | ## Built-in assets | |
| 53 | + | ||
| 54 | + | Because pages reference only Makenot.work, we ship a small set of on-platform assets you can use without uploading anything. | |
| 55 | + | ||
| 56 | + | **Fonts** (`/static/fonts/`): Lato (`Lato-Regular.woff2`, `Lato-Bold.woff2`), IBM Plex Mono (`IBMPlexMono-Regular.woff2`, `IBMPlexMono-Bold.woff2`), and the Young Serif display face (`ysrf.woff2`). Use them with `@font-face`, or just name the system stack: | |
| 57 | + | ||
| 58 | + | ```css | |
| 59 | + | @font-face { | |
| 60 | + | font-family: "Plex Mono"; | |
| 61 | + | src: url(/static/fonts/IBMPlexMono-Regular.woff2) format("woff2"); | |
| 62 | + | } | |
| 63 | + | .code { font-family: "Plex Mono", monospace; } | |
| 64 | + | ``` | |
| 65 | + | ||
| 66 | + | **Background patterns** (`/static/patterns/`): tileable SVGs in a neutral gray that sit on light or dark backgrounds — `dots.svg`, `grid.svg`, `diagonal.svg`, `checker.svg`. | |
| 67 | + | ||
| 68 | + | ```css | |
| 69 | + | .hero { | |
| 70 | + | background-color: #0c1a2c; | |
| 71 | + | background-image: url(/static/patterns/diagonal.svg); | |
| 72 | + | } | |
| 73 | + | ``` | |
| 74 | + | ||
| 75 | + | ## Examples | |
| 76 | + | ||
| 77 | + | A simple hero on a project page: | |
| 78 | + | ||
| 79 | + | ```html | |
| 80 | + | <section class="hero"> | |
| 81 | + | <h1>Field Recordings, Vol. 3</h1> | |
| 82 | + | <p>Twelve tracks from a winter in the Cascades.</p> | |
| 83 | + | </section> | |
| 84 | + | ``` | |
| 85 | + | ||
| 86 | + | ```css | |
| 87 | + | .hero { | |
| 88 | + | padding: 4rem 2rem; | |
| 89 | + | text-align: center; | |
| 90 | + | background: #10243a; | |
| 91 | + | color: #eef; | |
| 92 | + | } | |
| 93 | + | .hero h1 { font-size: 2.5rem; margin: 0 0 .5rem; } | |
| 94 | + | ``` | |
| 95 | + | ||
| 96 | + | Styling a system slot so it fits the page: | |
| 97 | + | ||
| 98 | + | ```css | |
| 99 | + | .mnw-buy { | |
| 100 | + | border: 2px solid #eef; | |
| 101 | + | border-radius: 8px; | |
| 102 | + | background: #0c1a2c; | |
| 103 | + | } | |
| 104 | + | .mnw-buy-cta { background: #eef; color: #10243a; } | |
| 105 | + | ``` | |
| 106 | + | ||
| 107 | + | A responsive two-column layout: | |
| 108 | + | ||
| 109 | + | ```css | |
| 110 | + | .grid { display: grid; grid-template-columns: 1fr 1fr; gap: 1.5rem; } | |
| 111 | + | @media (max-width: 700px) { .grid { grid-template-columns: 1fr; } } | |
| 112 | + | ``` | |
| 113 | + | ||
| 114 | + | ## Moderation | |
| 115 | + | ||
| 116 | + | Custom pages are public and subject to the acceptable-use policy. Pages that violate it can be cleared by moderators; your original source is preserved so it can be restored on a successful appeal. Questions go to info@makenot.work. |
| @@ -687,6 +687,7 @@ mod tests { | |||
| 687 | 687 | build_host_linux: None, | |
| 688 | 688 | build_host_darwin: None, | |
| 689 | 689 | cdn_base_url: None, | |
| 690 | + | user_pages_host: std::sync::Arc::from("u.localhost"), | |
| 690 | 691 | postmark_inbound_webhook_token: None, | |
| 691 | 692 | internal_shared_secret: None, | |
| 692 | 693 | cli_service_token: None, | |
| @@ -756,6 +757,7 @@ mod tests { | |||
| 756 | 757 | build_host_linux: None, | |
| 757 | 758 | build_host_darwin: None, | |
| 758 | 759 | cdn_base_url: None, | |
| 760 | + | user_pages_host: std::sync::Arc::from("u.localhost"), | |
| 759 | 761 | postmark_inbound_webhook_token: None, | |
| 760 | 762 | internal_shared_secret: None, | |
| 761 | 763 | cli_service_token: None, |
| @@ -78,6 +78,10 @@ pub struct Config { | |||
| 78 | 78 | /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work"). | |
| 79 | 79 | /// When set, free content downloads are served via CDN instead of presigned S3 URLs. | |
| 80 | 80 | pub cdn_base_url: Option<String>, | |
| 81 | + | /// Hostname that serves creator custom pages (e.g. "u.makenot.work"). | |
| 82 | + | /// Cookieless and strict-CSP, isolated from the apex. Defaults to "u." + | |
| 83 | + | /// the host_url host; override via USER_PAGES_HOST. | |
| 84 | + | pub user_pages_host: Arc<str>, | |
| 81 | 85 | /// Bearer token for authenticating Postmark inbound email webhook (optional). | |
| 82 | 86 | pub postmark_inbound_webhook_token: Option<String>, | |
| 83 | 87 | /// Shared secret for HMAC-signed internal API requests to MT. | |
| @@ -330,6 +334,11 @@ impl Config { | |||
| 330 | 334 | // CDN base URL - optional, when unset all downloads use presigned S3 URLs | |
| 331 | 335 | let cdn_base_url = std::env::var("CDN_BASE_URL").ok(); | |
| 332 | 336 | ||
| 337 | + | let user_pages_host = std::env::var("USER_PAGES_HOST") | |
| 338 | + | .ok() | |
| 339 | + | .filter(|h| !h.is_empty()) | |
| 340 | + | .unwrap_or_else(|| default_user_pages_host(&host_url)); | |
| 341 | + | ||
| 333 | 342 | // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset | |
| 334 | 343 | let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok(); | |
| 335 | 344 | ||
| @@ -378,6 +387,7 @@ impl Config { | |||
| 378 | 387 | build_host_linux, | |
| 379 | 388 | build_host_darwin, | |
| 380 | 389 | cdn_base_url, | |
| 390 | + | user_pages_host: Arc::from(user_pages_host), | |
| 381 | 391 | postmark_inbound_webhook_token, | |
| 382 | 392 | internal_shared_secret, | |
| 383 | 393 | cli_service_token, | |
| @@ -391,6 +401,31 @@ impl Config { | |||
| 391 | 401 | pub fn socket_addr(&self) -> SocketAddr { | |
| 392 | 402 | SocketAddr::new(self.host, self.port) | |
| 393 | 403 | } | |
| 404 | + | ||
| 405 | + | /// Build the URL policy that gates every reference in creator custom pages. | |
| 406 | + | /// A page may reference the apex, the user-pages host, and the CDN -- nothing | |
| 407 | + | /// else. The base origin is the user-pages host (where pages render). | |
| 408 | + | pub fn custom_pages_policy(&self) -> Option<crate::custom_pages::UrlPolicy> { | |
| 409 | + | let mut hosts = vec![self.user_pages_host.to_string()]; | |
| 410 | + | if let Some(apex) = host_of(&self.host_url) { | |
| 411 | + | hosts.push(apex); | |
| 412 | + | } | |
| 413 | + | if let Some(cdn) = self.cdn_base_url.as_deref().and_then(host_of) { | |
| 414 | + | hosts.push(cdn); | |
| 415 | + | } | |
| 416 | + | let base = format!("https://{}/", self.user_pages_host); | |
| 417 | + | crate::custom_pages::UrlPolicy::new(&base, hosts).ok() | |
| 418 | + | } | |
| 419 | + | } | |
| 420 | + | ||
| 421 | + | /// Extract the bare host from an absolute URL (no scheme/port/path). | |
| 422 | + | fn host_of(url: &str) -> Option<String> { | |
| 423 | + | url::Url::parse(url).ok().and_then(|u| u.host_str().map(str::to_string)) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | /// Default user-pages host: `u.` prefixed onto the host_url's host. | |
| 427 | + | fn default_user_pages_host(host_url: &str) -> String { | |
| 428 | + | host_of(host_url).map_or_else(|| "u.localhost".to_string(), |h| format!("u.{h}")) | |
| 394 | 429 | } | |
| 395 | 430 | ||
| 396 | 431 | impl StorageConfig { | |
| @@ -560,6 +595,7 @@ impl std::fmt::Debug for Config { | |||
| 560 | 595 | .field("build_host_linux", &self.build_host_linux) | |
| 561 | 596 | .field("build_host_darwin", &self.build_host_darwin) | |
| 562 | 597 | .field("cdn_base_url", &self.cdn_base_url) | |
| 598 | + | .field("user_pages_host", &self.user_pages_host) | |
| 563 | 599 | .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]")) | |
| 564 | 600 | .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]")) | |
| 565 | 601 | .field("cli_service_token", &self.cli_service_token.as_ref().map(|_| "[REDACTED]")) | |
| @@ -714,6 +750,7 @@ mod tests { | |||
| 714 | 750 | build_host_linux: None, | |
| 715 | 751 | build_host_darwin: None, | |
| 716 | 752 | cdn_base_url: None, | |
| 753 | + | user_pages_host: Arc::from("u.localhost"), | |
| 717 | 754 | postmark_inbound_webhook_token: None, | |
| 718 | 755 | internal_shared_secret: None, | |
| 719 | 756 | cli_service_token: None, |
| @@ -0,0 +1,710 @@ | |||
| 1 | + | //! CSS sanitization for custom pages, built on [`lightningcss`]. | |
| 2 | + | //! | |
| 3 | + | //! The job is to take creator CSS and make it safe to inline on a public page | |
| 4 | + | //! without it escaping the user canvas or reaching off-platform. The pipeline: | |
| 5 | + | //! | |
| 6 | + | //! 1. **Parse** the creator's CSS to an AST (nesting enabled, error-recovery on | |
| 7 | + | //! so one bad rule doesn't discard the sheet). | |
| 8 | + | //! 2. **Visit** it once to: drop at-rules outside the allowlist, validate every | |
| 9 | + | //! `url()` (off-platform URLs are neutralized), strip system-slot hiding | |
| 10 | + | //! properties on `.mnw-*` selectors, enforce the strobe budget, flag | |
| 11 | + | //! `expression()`, and count rules/selectors against the DoS caps. | |
| 12 | + | //! 3. **Scope** by partitioning rules into ones that select elements (style, | |
| 13 | + | //! `@media`, `@supports`, `@layer` blocks) and ones that are global by nature | |
| 14 | + | //! (`@keyframes`, `@font-face`, `@page`, `@layer` statements). The former are | |
| 15 | + | //! re-emitted nested inside `.user-canvas#uc-{owner}` and flattened by | |
| 16 | + | //! lightningcss, so scoping is done by the engine's own spec-compliant | |
| 17 | + | //! nesting resolver rather than fragile string surgery. Selectors that try to | |
| 18 | + | //! escape (`html`, `body`, `:root`, `*`) become `.user-canvas html` etc. and | |
| 19 | + | //! match nothing outside the canvas. | |
| 20 | + | //! 4. **Append** a reduced-motion override as the final rule. | |
| 21 | + | //! | |
| 22 | + | //! The parse -> print -> wrap -> reparse round-trip is also what makes brace | |
| 23 | + | //! injection impossible: a stray `}` in creator input is a parse error, never a | |
| 24 | + | //! literal that could close the wrapper early. | |
| 25 | + | ||
| 26 | + | use std::convert::Infallible; | |
| 27 | + | ||
| 28 | + | use lightningcss::declaration::DeclarationBlock; | |
| 29 | + | use lightningcss::properties::Property; | |
| 30 | + | use lightningcss::properties::custom::Function; | |
| 31 | + | use lightningcss::rules::{CssRule, CssRuleList}; | |
| 32 | + | use lightningcss::selector::{Component, Selector, SelectorList}; | |
| 33 | + | use lightningcss::stylesheet::{ParserFlags, ParserOptions, PrinterOptions, StyleSheet}; | |
| 34 | + | use lightningcss::targets::{Features, Targets}; | |
| 35 | + | use lightningcss::values::url::Url; | |
| 36 | + | use lightningcss::visit_types; | |
| 37 | + | use lightningcss::visitor::{Visit, VisitTypes, Visitor}; | |
| 38 | + | ||
| 39 | + | use super::url_filter::{UrlPolicy, resolve_internal_url}; | |
| 40 | + | use super::{MAX_RULES, MAX_SELECTORS, Rejection, RejectionKind}; | |
| 41 | + | ||
| 42 | + | /// Sanitize creator CSS for a profile or project page, scoping it to | |
| 43 | + | /// `.user-canvas#uc-{scope_id}`. See [`scope_and_sanitize`]. | |
| 44 | + | pub fn sanitize_css(input: &str, scope_id: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) { | |
| 45 | + | scope_and_sanitize(input, "user-canvas", "uc", scope_id, policy) | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | /// Sanitize a project's CSS for one of its item pages, scoping it to | |
| 49 | + | /// `.item-canvas#ic-{project_id}`. Item pages have no HTML of their own; they | |
| 50 | + | /// wear the parent project's styling re-scoped to the item canvas root. | |
| 51 | + | pub fn sanitize_item_css(input: &str, project_id: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) { | |
| 52 | + | scope_and_sanitize(input, "item-canvas", "ic", project_id, policy) | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | /// Sanitize creator CSS, scoping it to `.{canvas_class}#{id_prefix}-{scope_id}`. | |
| 56 | + | /// | |
| 57 | + | /// `scope_id` must be an id-safe token (the owner/project UUID); anything else | |
| 58 | + | /// is refused outright. `canvas_class`/`id_prefix` are internal constants. | |
| 59 | + | /// Returns the sanitized, scoped stylesheet plus every reference stripped along | |
| 60 | + | /// the way. On a fatal parse failure or a complexity-cap breach, returns empty | |
| 61 | + | /// CSS and a single explanatory rejection -- a page that can't be made safe | |
| 62 | + | /// renders as the platform default, never partially. | |
| 63 | + | fn scope_and_sanitize( | |
| 64 | + | input: &str, | |
| 65 | + | canvas_class: &str, | |
| 66 | + | id_prefix: &str, | |
| 67 | + | scope_id: &str, | |
| 68 | + | policy: &UrlPolicy, | |
| 69 | + | ) -> (String, Vec<Rejection>) { | |
| 70 | + | if input.trim().is_empty() { | |
| 71 | + | return (String::new(), Vec::new()); | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | if !is_id_safe(scope_id) { | |
| 75 | + | return ( | |
| 76 | + | String::new(), | |
| 77 | + | vec![Rejection { | |
| 78 | + | kind: RejectionKind::MalformedCss, | |
| 79 | + | location: "css".into(), | |
| 80 | + | original_value: scope_id.to_string(), | |
| 81 | + | reason: "internal: unsafe owner scope".into(), | |
| 82 | + | }], | |
| 83 | + | ); | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | let mut stylesheet = match StyleSheet::parse(input, parser_options()) { | |
| 87 | + | Ok(s) => s, | |
| 88 | + | Err(_) => { | |
| 89 | + | return ( | |
| 90 | + | String::new(), | |
| 91 | + | vec![Rejection { | |
| 92 | + | kind: RejectionKind::MalformedCss, | |
| 93 | + | location: "css".into(), | |
| 94 | + | original_value: String::new(), | |
| 95 | + | reason: "CSS could not be parsed".into(), | |
| 96 | + | }], | |
| 97 | + | ); | |
| 98 | + | } | |
| 99 | + | }; | |
| 100 | + | ||
| 101 | + | let mut sanitizer = CssSanitizer { | |
| 102 | + | policy, | |
| 103 | + | rejections: Vec::new(), | |
| 104 | + | rule_count: 0, | |
| 105 | + | selector_count: 0, | |
| 106 | + | }; | |
| 107 | + | // Our visitor never returns Err. | |
| 108 | + | let _: Result<(), Infallible> = stylesheet.visit(&mut sanitizer); | |
| 109 | + | ||
| 110 | + | if sanitizer.rule_count > MAX_RULES || sanitizer.selector_count > MAX_SELECTORS { | |
| 111 | + | return ( | |
| 112 | + | String::new(), | |
| 113 | + | vec![Rejection { | |
| 114 | + | kind: RejectionKind::ComplexityLimit, | |
| 115 | + | location: "css".into(), | |
| 116 | + | original_value: format!( | |
| 117 | + | "{} rules, {} selectors", | |
| 118 | + | sanitizer.rule_count, sanitizer.selector_count | |
| 119 | + | ), | |
| 120 | + | reason: format!("stylesheet too complex (limit {MAX_RULES} rules, {MAX_SELECTORS} selectors)"), | |
| 121 | + | }], | |
| 122 | + | ); | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | let mut rejections = sanitizer.rejections; | |
| 126 | + | ||
| 127 | + | // Partition surviving rules: element-selecting rules get scoped; rules that | |
| 128 | + | // are global by nature stay top-level (they have no document selectors, and | |
| 129 | + | // they cannot legally nest inside a style rule anyway). | |
| 130 | + | let rules = std::mem::take(&mut stylesheet.rules.0); | |
| 131 | + | let mut global = Vec::new(); | |
| 132 | + | let mut scopable = Vec::new(); | |
| 133 | + | for rule in rules { | |
| 134 | + | match rule { | |
| 135 | + | CssRule::Ignored => {} | |
| 136 | + | CssRule::Keyframes(_) | |
| 137 | + | | CssRule::FontFace(_) | |
| 138 | + | | CssRule::Page(_) | |
| 139 | + | | CssRule::LayerStatement(_) => global.push(rule), | |
| 140 | + | _ => scopable.push(rule), | |
| 141 | + | } | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | let scope_selector = format!(".{canvas_class}#{id_prefix}-{scope_id}"); | |
| 145 | + | ||
| 146 | + | let global_css = print_rules(global); | |
| 147 | + | let scopable_css = print_rules(scopable); | |
| 148 | + | ||
| 149 | + | // Wrap the element-selecting rules in the canvas selector and let | |
| 150 | + | // lightningcss flatten the nesting (scoping done by the engine). | |
| 151 | + | let flat_scoped = if scopable_css.trim().is_empty() { | |
| 152 | + | String::new() | |
| 153 | + | } else { | |
| 154 | + | let wrapped = format!("{scope_selector} {{\n{scopable_css}\n}}"); | |
| 155 | + | match StyleSheet::parse(&wrapped, parser_options()) { | |
| 156 | + | Ok(sheet) => sheet | |
| 157 | + | .to_css(PrinterOptions { | |
| 158 | + | targets: Targets { | |
| 159 | + | browsers: None, | |
| 160 | + | include: Features::Nesting, | |
| 161 | + | exclude: Features::empty(), | |
| 162 | + | }, | |
| 163 | + | ..Default::default() | |
| 164 | + | }) | |
| 165 | + | .map(|r| r.code) | |
| 166 | + | .unwrap_or_default(), | |
| 167 | + | Err(_) => { | |
| 168 | + | // Should not happen on already-sanitized input; fail safe. | |
| 169 | + | rejections.push(Rejection { | |
| 170 | + | kind: RejectionKind::MalformedCss, | |
| 171 | + | location: "css".into(), | |
| 172 | + | original_value: String::new(), | |
| 173 | + | reason: "internal: re-scope failed".into(), | |
| 174 | + | }); | |
| 175 | + | String::new() | |
| 176 | + | } | |
| 177 | + | } | |
| 178 | + | }; | |
| 179 | + | ||
| 180 | + | // Reduced-motion override, always last (decision #3). Scoped to the canvas. | |
| 181 | + | let reduced_motion = format!( | |
| 182 | + | "@media (prefers-reduced-motion: reduce){{{sel},{sel} *{{animation:none!important;transition:none!important}}}}", | |
| 183 | + | sel = scope_selector | |
| 184 | + | ); | |
| 185 | + | ||
| 186 | + | let mut out = String::new(); | |
| 187 | + | if !global_css.trim().is_empty() { | |
| 188 | + | out.push_str(global_css.trim()); | |
| 189 | + | out.push('\n'); | |
| 190 | + | } | |
| 191 | + | if !flat_scoped.trim().is_empty() { | |
| 192 | + | out.push_str(flat_scoped.trim()); | |
| 193 | + | out.push('\n'); | |
| 194 | + | } | |
| 195 | + | out.push_str(&reduced_motion); | |
| 196 | + | ||
| 197 | + | (out, rejections) | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | fn parser_options<'o, 'i>() -> ParserOptions<'o, 'i> { | |
| 201 | + | ParserOptions { | |
| 202 | + | // Nesting is standard CSS; let creators use it and let us wrap with it. | |
| 203 | + | flags: ParserFlags::NESTING, | |
| 204 | + | // One malformed rule shouldn't discard the whole sheet. | |
| 205 | + | error_recovery: true, | |
| 206 | + | ..Default::default() | |
| 207 | + | } | |
| 208 | + | } | |
| 209 | + | ||
| 210 | + | /// Print a set of rules to CSS (non-minified, no nesting transform). | |
| 211 | + | fn print_rules(rules: Vec<CssRule<'_>>) -> String { | |
| 212 | + | if rules.is_empty() { | |
| 213 | + | return String::new(); | |
| 214 | + | } | |
| 215 | + | let sheet = StyleSheet::new(Vec::new(), CssRuleList(rules), ParserOptions::default()); | |
| 216 | + | sheet.to_css(PrinterOptions::default()).map(|r| r.code).unwrap_or_default() | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | /// An id token safe to embed in a CSS id selector: ASCII alphanumerics and `-`. | |
| 220 | + | fn is_id_safe(s: &str) -> bool { | |
| 221 | + | !s.is_empty() && s.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-') | |
| 222 | + | } | |
| 223 | + | ||
| 224 | + | /// The single-pass AST sanitizer. | |
| 225 | + | struct CssSanitizer<'p> { | |
| 226 | + | policy: &'p UrlPolicy, | |
| 227 | + | rejections: Vec<Rejection>, | |
| 228 | + | rule_count: usize, | |
| 229 | + | selector_count: usize, | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | impl<'i, 'p> Visitor<'i> for CssSanitizer<'p> { | |
| 233 | + | type Error = Infallible; | |
| 234 | + | ||
| 235 | + | fn visit_types(&self) -> VisitTypes { | |
| 236 | + | visit_types!(RULES | URLS | FUNCTIONS) | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | fn visit_rule(&mut self, rule: &mut CssRule<'i>) -> Result<(), Self::Error> { | |
| 240 | + | self.rule_count += 1; | |
| 241 | + | ||
| 242 | + | // At-rule allowlist. Anything not explicitly allowed is replaced with | |
| 243 | + | // CssRule::Ignored (prints nothing) and recorded. Allowed at-rules fall | |
| 244 | + | // through to the recursion below so their contents are still cleaned. | |
| 245 | + | let blocked_name: Option<&str> = match rule { | |
| 246 | + | CssRule::Import(_) => Some("@import"), | |
| 247 | + | CssRule::Namespace(_) => Some("@namespace"), | |
| 248 | + | CssRule::MozDocument(_) => Some("@-moz-document"), | |
| 249 | + | CssRule::CustomMedia(_) => Some("@custom-media"), | |
| 250 | + | CssRule::Property(_) => Some("@property"), | |
| 251 | + | CssRule::Viewport(_) => Some("@viewport"), | |
| 252 | + | CssRule::CounterStyle(_) => Some("@counter-style"), | |
| 253 | + | CssRule::FontPaletteValues(_) => Some("@font-palette-values"), | |
| 254 | + | CssRule::FontFeatureValues(_) => Some("@font-feature-values"), | |
| 255 | + | CssRule::Container(_) => Some("@container"), | |
| 256 | + | CssRule::Scope(_) => Some("@scope"), | |
| 257 | + | CssRule::StartingStyle(_) => Some("@starting-style"), | |
| 258 | + | CssRule::ViewTransition(_) => Some("@view-transition"), | |
| 259 | + | CssRule::Unknown(_) => Some("unknown at-rule"), | |
| 260 | + | _ => None, | |
| 261 | + | }; | |
| 262 | + | ||
| 263 | + | if let Some(name) = blocked_name { | |
| 264 | + | self.rejections.push(Rejection { | |
| 265 | + | kind: RejectionKind::BlockedAtRule, | |
| 266 | + | location: name.to_string(), | |
| 267 | + | original_value: name.to_string(), | |
| 268 | + | reason: format!("{name} is not allowed in custom pages"), | |
| 269 | + | }); | |
| 270 | + | *rule = CssRule::Ignored; | |
| 271 | + | return Ok(()); | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // Style-rule-specific cleanups, with selector context in hand. | |
| 275 | + | if let CssRule::Style(style) = rule { | |
| 276 | + | self.selector_count += style.selectors.0.len(); | |
| 277 | + | if selectors_target_system_slot(&style.selectors) { | |
| 278 | + | strip_hiding_properties(&mut style.declarations, &mut self.rejections); | |
| 279 | + | } | |
| 280 | + | enforce_animation_budget(&mut style.declarations, &mut self.rejections); | |
| 281 | + | } | |
| 282 | + | ||
| 283 | + | // Recurse into declarations (url()/expression()) and nested rules. | |
| 284 | + | rule.visit_children(self) | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | fn visit_url(&mut self, url: &mut Url<'i>) -> Result<(), Self::Error> { | |
| 288 | + | if let Err(rejection) = resolve_internal_url(&url.url, self.policy, "css url()") { | |
| 289 | + | self.rejections.push(rejection); | |
| 290 | + | // Neutralize: an empty url() resolves to the current document | |
| 291 | + | // (same-origin), never the off-platform target. | |
| 292 | + | url.url = "".into(); | |
| 293 | + | } | |
| 294 | + | Ok(()) | |
| 295 | + | } | |
| 296 | + | ||
| 297 | + | fn visit_function(&mut self, function: &mut Function<'i>) -> Result<(), Self::Error> { | |
| 298 | + | // expression() is dead in every browser we support; record it so the | |
| 299 | + | // creator sees why it's gone, and recurse for any url() inside it. | |
| 300 | + | if function.name.as_ref().eq_ignore_ascii_case("expression") { | |
| 301 | + | self.rejections.push(Rejection { | |
| 302 | + | kind: RejectionKind::BlockedFunction, | |
| 303 | + | location: "css".into(), | |
| 304 | + | original_value: "expression()".into(), | |
| 305 | + | reason: "the expression() function is not allowed".into(), | |
| 306 | + | }); | |
| 307 | + | } | |
| 308 | + | function.visit_children(self) | |
| 309 | + | } | |
| 310 | + | } | |
| 311 | + | ||
| 312 | + | /// True if any selector in the list targets a `.mnw-*` system-slot class | |
| 313 | + | /// (directly or inside `:is()`/`:where()`/`:not()`/`:has()`). | |
| 314 | + | fn selectors_target_system_slot(list: &SelectorList) -> bool { | |
| 315 | + | list.0.iter().any(selector_has_system_class) | |
| 316 | + | } | |
| 317 | + | ||
| 318 | + | fn selector_has_system_class(selector: &Selector) -> bool { | |
| 319 | + | selector.iter_raw_match_order().any(component_has_system_class) | |
| 320 | + | } | |
| 321 | + | ||
| 322 | + | fn component_has_system_class(component: &Component) -> bool { | |
| 323 | + | match component { | |
| 324 | + | Component::Class(ident) => ident.0.starts_with("mnw-"), | |
| 325 | + | Component::Is(list) | |
| 326 | + | | Component::Where(list) | |
| 327 | + | | Component::Negation(list) | |
| 328 | + | | Component::Has(list) => list.iter().any(selector_has_system_class), | |
| 329 | + | Component::Any(_, list) => list.iter().any(selector_has_system_class), | |
| 330 | + | Component::Host(Some(inner)) => selector_has_system_class(inner), | |
| 331 | + | _ => false, | |
| 332 | + | } | |
| 333 | + | } | |
| 334 | + | ||
| 335 | + | /// Remove declarations that would hide a system slot, preserving the rest of | |
| 336 | + | /// the rule. Records one rejection per dropped property. | |
| 337 | + | fn strip_hiding_properties(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) { | |
| 338 | + | for list in [&mut decls.declarations, &mut decls.important_declarations] { | |
| 339 | + | list.retain(|prop| { | |
| 340 | + | if is_hiding_property(prop) { | |
| 341 | + | rejections.push(Rejection { | |
| 342 | + | kind: RejectionKind::HidingProperty, | |
| 343 | + | location: ".mnw-* rule".into(), | |
| 344 | + | original_value: prop_string(prop), | |
| 345 | + | reason: "system slots (.mnw-*) cannot be hidden".into(), | |
| 346 | + | }); | |
| 347 | + | false | |
| 348 | + | } else { | |
| 349 | + | true | |
| 350 | + | } | |
| 351 | + | }); | |
| 352 | + | } | |
| 353 | + | } | |
| 354 | + | ||
| 355 | + | /// Whether a property+value combination hides an element. Matched against the | |
| 356 | + | /// serialized declaration so we don't have to enumerate every typed variant. | |
| 357 | + | fn is_hiding_property(prop: &Property) -> bool { | |
| 358 | + | let norm = normalize(&prop_string(prop)); | |
| 359 | + | if let Some(rest) = norm.strip_prefix("opacity:") { | |
| 360 | + | return rest.parse::<f32>().map(|v| v < 0.1).unwrap_or(false); | |
| 361 | + | } | |
| 362 | + | matches!( | |
| 363 | + | norm.as_str(), | |
| 364 | + | "display:none" | |
| 365 | + | | "visibility:hidden" | |
| 366 | + | | "visibility:collapse" | |
| 367 | + | | "pointer-events:none" | |
| 368 | + | | "width:0" | |
| 369 | + | | "width:0px" | |
| 370 | + | | "height:0" | |
| 371 | + | | "height:0px" | |
| 372 | + | ) || (norm.starts_with("transform:") && norm.contains("scale(0)")) | |
| 373 | + | } | |
| 374 | + | ||
| 375 | + | /// Drop infinite animations faster than 2s (strobe guard, decision #3). The | |
| 376 | + | /// reduced-motion override handles accessibility; this caps the worst abuse for | |
| 377 | + | /// everyone else. | |
| 378 | + | fn enforce_animation_budget(decls: &mut DeclarationBlock, rejections: &mut Vec<Rejection>) { | |
| 379 | + | let mut has_infinite = false; | |
| 380 | + | let mut min_duration: Option<f32> = None; | |
| 381 | + | ||
| 382 | + | for list in [&decls.declarations, &decls.important_declarations] { | |
| 383 | + | for prop in list { | |
| 384 | + | // Lowercased but whitespace-preserved: duration tokens like `1s` | |
| 385 | + | // must stay split from neighbouring keywords. | |
| 386 | + | let raw = prop_string(prop).to_ascii_lowercase(); | |
| 387 | + | if raw.contains("infinite") { | |
| 388 | + | has_infinite = true; | |
| 389 | + | } | |
| 390 | + | if let Some(rest) = raw.strip_prefix("animation-duration:") { | |
| 391 | + | update_min_duration(rest, &mut min_duration); | |
| 392 | + | } else if let Some(rest) = raw.strip_prefix("animation:") { | |
| 393 | + | update_min_duration(rest, &mut min_duration); | |
| 394 | + | } | |
| 395 | + | } | |
| 396 | + | } | |
| 397 | + | ||
| 398 | + | let strobe = has_infinite && min_duration.map(|d| d < 2.0).unwrap_or(false); | |
| 399 | + | if !strobe { | |
| 400 | + | return; | |
| 401 | + | } | |
| 402 | + | ||
| 403 | + | let mut dropped = false; | |
| 404 | + | for list in [&mut decls.declarations, &mut decls.important_declarations] { | |
| 405 | + | list.retain(|prop| { | |
| 406 | + | let norm = normalize(&prop_string(prop)); | |
| 407 | + | if norm.starts_with("animation") { | |
| 408 | + | dropped = true; | |
| 409 | + | false | |
| 410 | + | } else { | |
| 411 | + | true | |
| 412 | + | } | |
| 413 | + | }); | |
| 414 | + | } | |
| 415 | + | if dropped { | |
| 416 | + | rejections.push(Rejection { | |
| 417 | + | kind: RejectionKind::AnimationBudget, | |
| 418 | + | location: "animation".into(), | |
| 419 | + | original_value: "infinite animation under 2s".into(), | |
| 420 | + | reason: "fast infinite animations are not allowed (strobe guard)".into(), | |
| 421 | + | }); | |
| 422 | + | } | |
| 423 | + | } | |
| 424 | + | ||
| 425 | + | fn update_min_duration(value: &str, min: &mut Option<f32>) { | |
| 426 | + | for token in value.split([' ', ',']) { | |
| 427 | + | if let Some(secs) = parse_seconds(token) { | |
| 428 | + | *min = Some(min.map_or(secs, |m| m.min(secs))); | |
| 429 | + | } | |
| 430 | + | } | |
| 431 | + | } | |
| 432 | + | ||
| 433 | + | /// Parse a CSS time token to seconds. Returns None for non-time tokens. | |
| 434 | + | fn parse_seconds(token: &str) -> Option<f32> { | |
| 435 | + | let t = token.trim(); | |
| 436 | + | if let Some(ms) = t.strip_suffix("ms") { | |
| 437 | + | ms.parse::<f32>().ok().map(|v| v / 1000.0) | |
| 438 | + | } else if let Some(s) = t.strip_suffix('s') { | |
| 439 | + | s.parse::<f32>().ok() | |
| 440 | + | } else { | |
| 441 | + | None | |
| 442 | + | } | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | fn prop_string(prop: &Property) -> String { | |
| 446 | + | prop.to_css_string(false, PrinterOptions::default()).unwrap_or_default() | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | /// Lowercase and strip ASCII whitespace, for value matching. | |
| 450 | + | fn normalize(s: &str) -> String { | |
| 451 | + | s.chars().filter(|c| !c.is_whitespace()).collect::<String>().to_ascii_lowercase() | |
| 452 | + | } | |
| 453 | + | ||
| 454 | + | #[cfg(test)] | |
| 455 | + | mod tests { | |
| 456 | + | use super::*; | |
| 457 | + | ||
| 458 | + | const SCOPE: &str = "11111111-1111-1111-1111-111111111111"; | |
| 459 | + | ||
| 460 | + | fn policy() -> UrlPolicy { | |
| 461 | + | UrlPolicy::new( | |
| 462 | + | "https://u.makenot.work/alice/proj", | |
| 463 | + | ["makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string()], | |
| 464 | + | ) | |
| 465 | + | .unwrap() | |
| 466 | + | } | |
| 467 | + | ||
| 468 | + | fn san(css: &str) -> (String, Vec<Rejection>) { | |
| 469 | + | sanitize_css(css, SCOPE, &policy()) | |
| 470 | + | } | |
| 471 | + | ||
| 472 | + | fn scoped(css: &str) -> String { | |
| 473 | + | san(css).0 | |
| 474 | + | } | |
| 475 | + | ||
| 476 | + | #[test] | |
| 477 | + | fn empty_input_is_empty() { | |
| 478 | + | assert_eq!(san("").0, ""); | |
| 479 | + | assert_eq!(san(" ").0, ""); | |
| 480 | + | } | |
| 481 | + | ||
| 482 | + | #[test] | |
| 483 | + | fn scopes_plain_selectors() { | |
| 484 | + | let out = scoped("p { color: red }"); | |
| 485 | + | assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 p")); | |
| 486 | + | } | |
| 487 | + | ||
| 488 | + | #[test] | |
| 489 | + | fn neutralizes_body_and_root_escape() { | |
| 490 | + | let out = scoped("body { background: blue } :root { color: green }"); | |
| 491 | + | // Both are confined under the canvas (descendant), matching nothing outside. | |
| 492 | + | assert!(out.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111 body")); | |
| 493 | + | assert!(!out.contains("\nbody")); | |
| 494 | + | assert!(!out.starts_with("body")); | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | #[test] | |
| 498 | + | fn rejects_import() { | |
| 499 | + | let (out, rej) = san("@import url(https://evil.com/x.css); p { color: red }"); | |
| 500 | + | assert!(!out.contains("@import")); |
Lines truncated
| @@ -0,0 +1,318 @@ | |||
| 1 | + | //! HTML sanitization for custom pages, built on [`ammonia`]. | |
| 2 | + | //! | |
| 3 | + | //! The policy is an explicit allowlist (see `plans/custom-pages.md`): structural | |
| 4 | + | //! and text elements plus media, no scripting, no embeds, no forms, no inline | |
| 5 | + | //! `style` attribute (all CSS goes in the dedicated CSS field, for a single | |
| 6 | + | //! sanitization path and better caching). Every URL-bearing attribute is routed | |
| 7 | + | //! through [`resolve_internal_url`], so a custom page can reference only MNW. | |
| 8 | + | //! | |
| 9 | + | //! Anything outside the allowlist is dropped by ammonia. Dropped *URLs* are | |
| 10 | + | //! additionally recorded as [`Rejection`]s for the editor's blocked-references | |
| 11 | + | //! panel -- the primary teaching surface for the closed-system rule. | |
| 12 | + | ||
| 13 | + | use std::borrow::Cow; | |
| 14 | + | use std::collections::{HashMap, HashSet}; | |
| 15 | + | use std::sync::{Arc, Mutex}; | |
| 16 | + | ||
| 17 | + | use super::url_filter::{UrlPolicy, resolve_internal_url, resolve_srcset}; | |
| 18 | + | use super::Rejection; | |
| 19 | + | ||
| 20 | + | /// Allowed element names. | |
| 21 | + | const ALLOWED_TAGS: &[&str] = &[ | |
| 22 | + | "a", "abbr", "article", "aside", "b", "blockquote", "br", "caption", "cite", | |
| 23 | + | "code", "col", "colgroup", "dd", "details", "div", "dl", "dt", "em", | |
| 24 | + | "figcaption", "figure", "footer", "h1", "h2", "h3", "h4", "h5", "h6", | |
| 25 | + | "header", "hr", "i", "img", "kbd", "li", "main", "mark", "nav", "ol", "p", | |
| 26 | + | "picture", "pre", "q", "s", "samp", "section", "small", "source", "span", | |
| 27 | + | "strong", "sub", "summary", "sup", "table", "tbody", "td", "tfoot", "th", | |
| 28 | + | "thead", "time", "tr", "u", "ul", "video", "audio", "track", | |
| 29 | + | ]; | |
| 30 | + | ||
| 31 | + | /// Attributes allowed on any element. | |
| 32 | + | const GENERIC_ATTRS: &[&str] = &["class", "id", "title", "lang", "dir"]; | |
| 33 | + | ||
| 34 | + | /// Tags whose entire content is discarded (not unwrapped) when the tag is | |
| 35 | + | /// stripped -- script/style bodies and metadata must never resurface as text. | |
| 36 | + | const CLEAN_CONTENT_TAGS: &[&str] = &[ | |
| 37 | + | "script", "style", "iframe", "object", "embed", "noscript", "template", | |
| 38 | + | "svg", "math", "frame", "frameset", "head", "title", "base", "meta", | |
| 39 | + | "link", "applet", "param", "canvas", "form", "input", "button", "select", | |
| 40 | + | "textarea", | |
| 41 | + | ]; | |
| 42 | + | ||
| 43 | + | /// Per-tag attribute allowlist. URL-bearing attributes here are still validated | |
| 44 | + | /// by the attribute filter; listing them only makes them *eligible*. | |
| 45 | + | fn tag_attributes() -> HashMap<&'static str, HashSet<&'static str>> { | |
| 46 | + | let set = |attrs: &[&'static str]| attrs.iter().copied().collect::<HashSet<_>>(); | |
| 47 | + | HashMap::from([ | |
| 48 | + | ("a", set(&["href"])), | |
| 49 | + | ("img", set(&["src", "alt", "width", "height", "loading", "srcset"])), | |
| 50 | + | ("source", set(&["src", "srcset", "type", "media", "width", "height"])), | |
| 51 | + | // autoplay dropped; looping silent video allowed (decision #4). | |
| 52 | + | ("video", set(&["src", "controls", "loop", "muted", "poster", "preload", "width", "height"])), | |
| 53 | + | // loop/muted/autoplay dropped -- no surprise / looping audio (decision #4). | |
| 54 | + | ("audio", set(&["src", "controls", "preload"])), | |
| 55 | + | ("track", set(&["src", "kind", "srclang", "label", "default"])), | |
| 56 | + | ("time", set(&["datetime"])), | |
| 57 | + | ("th", set(&["colspan", "rowspan", "scope"])), | |
| 58 | + | ("td", set(&["colspan", "rowspan", "scope"])), | |
| 59 | + | ("col", set(&["span"])), | |
| 60 | + | ("colgroup", set(&["span"])), | |
| 61 | + | ("details", set(&["open"])), | |
| 62 | + | ("ol", set(&["start", "reversed"])), | |
| 63 | + | ]) | |
| 64 | + | } | |
| 65 | + | ||
| 66 | + | /// Which attributes carry URLs, and how to parse them. | |
| 67 | + | enum UrlAttr { | |
| 68 | + | Single, | |
| 69 | + | SrcSet, | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | fn url_attribute(element: &str, attribute: &str) -> Option<UrlAttr> { | |
| 73 | + | match (element, attribute) { | |
| 74 | + | ("a", "href") => Some(UrlAttr::Single), | |
| 75 | + | ("img" | "source" | "video" | "audio" | "track", "src") => Some(UrlAttr::Single), | |
| 76 | + | ("video", "poster") => Some(UrlAttr::Single), | |
| 77 | + | ("img" | "source", "srcset") => Some(UrlAttr::SrcSet), | |
| 78 | + | _ => None, | |
| 79 | + | } | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | /// Sanitize user HTML. Returns the cleaned markup plus every URL the sanitizer | |
| 83 | + | /// stripped (for the blocked-references panel). The output references only MNW | |
| 84 | + | /// and contains no scripting, embeds, forms, or inline styles. | |
| 85 | + | pub fn sanitize_html(input: &str, policy: &UrlPolicy) -> (String, Vec<Rejection>) { | |
| 86 | + | let rejections: Arc<Mutex<Vec<Rejection>>> = Arc::new(Mutex::new(Vec::new())); | |
| 87 | + | ||
| 88 | + | let tags: HashSet<&str> = ALLOWED_TAGS.iter().copied().collect(); | |
| 89 | + | let generic: HashSet<&str> = GENERIC_ATTRS.iter().copied().collect(); | |
| 90 | + | let clean_content: HashSet<&str> = CLEAN_CONTENT_TAGS.iter().copied().collect(); | |
| 91 | + | let per_tag = tag_attributes(); | |
| 92 | + | ||
| 93 | + | let filter_policy = policy.clone(); | |
| 94 | + | let filter_sink = Arc::clone(&rejections); | |
| 95 | + | ||
| 96 | + | let mut builder = ammonia::Builder::default(); | |
| 97 | + | builder | |
| 98 | + | .tags(tags) | |
| 99 | + | .generic_attributes(generic) | |
| 100 | + | .tag_attributes(per_tag) | |
| 101 | + | .clean_content_tags(clean_content) | |
| 102 | + | // Let candidate schemes through ammonia's built-in check so our | |
| 103 | + | // attribute filter is the single authority -- it rejects everything | |
| 104 | + | // that does not resolve to on-platform https, and recording happens | |
| 105 | + | // there (ammonia's own scheme drop is silent). Safe because the filter | |
| 106 | + | // covers every URL-bearing attribute on every allowed tag. | |
| 107 | + | .url_schemes(HashSet::from([ | |
| 108 | + | "https", "http", "data", "javascript", "mailto", "ftp", "blob", "file", "vbscript", | |
| 109 | + | ])) | |
| 110 | + | .url_relative(ammonia::UrlRelative::PassThrough) | |
| 111 | + | // User-authored anchors never influence ranking and are flagged as | |
| 112 | + | // user-generated content (decision: per-page link rel). | |
| 113 | + | .link_rel(Some("nofollow ugc")) | |
| 114 | + | .strip_comments(true) | |
| 115 | + | .attribute_filter(move |element, attribute, value| { | |
| 116 | + | match url_attribute(element, attribute) { | |
| 117 | + | None => Some(Cow::Borrowed(value)), | |
| 118 | + | Some(kind) => { | |
| 119 | + | let location = format!("{element} {attribute}"); | |
| 120 | + | let result = match kind { | |
| 121 | + | UrlAttr::Single => resolve_internal_url(value, &filter_policy, &location), | |
| 122 | + | UrlAttr::SrcSet => resolve_srcset(value, &filter_policy, &location), | |
| 123 | + | }; | |
| 124 | + | match result { | |
| 125 | + | Ok(v) => Some(Cow::Owned(v)), | |
| 126 | + | Err(rej) => { | |
| 127 | + | filter_sink.lock().expect("rejection sink poisoned").push(rej); | |
| 128 | + | None | |
| 129 | + | } | |
| 130 | + | } | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | }); | |
| 134 | + | ||
| 135 | + | let cleaned = builder.clean(input).to_string(); | |
| 136 | + | let collected = std::mem::take(&mut *rejections.lock().expect("rejection sink poisoned")); | |
| 137 | + | (cleaned, collected) | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | #[cfg(test)] | |
| 141 | + | mod tests { | |
| 142 | + | use super::super::RejectionKind; | |
| 143 | + | use super::*; | |
| 144 | + | ||
| 145 | + | fn policy() -> UrlPolicy { | |
| 146 | + | UrlPolicy::new( | |
| 147 | + | "https://u.makenot.work/alice/proj", | |
| 148 | + | ["makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string()], | |
| 149 | + | ) | |
| 150 | + | .unwrap() | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | fn clean(html: &str) -> String { | |
| 154 | + | sanitize_html(html, &policy()).0 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | #[test] | |
| 158 | + | fn keeps_allowed_structure() { | |
| 159 | + | let out = clean("<section><h1 class=\"t\">Hi</h1><p>Hello <strong>world</strong></p></section>"); | |
| 160 | + | assert!(out.contains("<section>")); | |
| 161 | + | assert!(out.contains("<h1 class=\"t\">")); | |
| 162 | + | assert!(out.contains("<strong>world</strong>")); | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | #[test] | |
| 166 | + | fn strips_script_and_its_content() { | |
| 167 | + | let out = clean("<p>ok</p><script>alert(1)</script>"); | |
| 168 | + | assert!(out.contains("ok")); | |
| 169 | + | assert!(!out.contains("alert")); | |
| 170 | + | assert!(!out.contains("<script")); | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | #[test] | |
| 174 | + | fn strips_style_tag_and_content() { | |
| 175 | + | let out = clean("<style>body{display:none}</style><p>hi</p>"); | |
| 176 | + | assert!(!out.to_lowercase().contains("display")); | |
| 177 | + | assert!(out.contains("hi")); | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | #[test] | |
| 181 | + | fn strips_inline_style_attribute() { | |
| 182 | + | let out = clean("<p style=\"color:red\">x</p>"); | |
| 183 | + | assert!(!out.contains("style")); | |
| 184 | + | assert!(out.contains("<p>x</p>")); | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | #[test] | |
| 188 | + | fn strips_event_handlers() { | |
| 189 | + | let out = clean("<div onclick=\"steal()\">x</div>"); | |
| 190 | + | assert!(!out.to_lowercase().contains("onclick")); | |
| 191 | + | assert!(!out.contains("steal")); | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn strips_iframe_object_embed_form() { | |
| 196 | + | for tag in ["iframe", "object", "embed", "form"] { | |
| 197 | + | let out = clean(&format!("<{tag}>x</{tag}><p>keep</p>")); | |
| 198 | + | assert!(!out.contains(&format!("<{tag}")), "{tag} must be stripped"); | |
| 199 | + | assert!(out.contains("keep")); | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | #[test] | |
| 204 | + | fn rejects_external_image_src_and_records_it() { | |
| 205 | + | let (out, rej) = sanitize_html("<img src=\"https://evil.com/x.png\" alt=\"a\">", &policy()); | |
| 206 | + | assert!(!out.contains("evil.com")); | |
| 207 | + | assert_eq!(rej.len(), 1); | |
| 208 | + | assert!(matches!(rej[0].kind, RejectionKind::ExternalUrl)); | |
| 209 | + | assert_eq!(rej[0].location, "img src"); | |
| 210 | + | } | |
| 211 | + | ||
| 212 | + | #[test] | |
| 213 | + | fn keeps_internal_and_relative_media() { | |
| 214 | + | let out = clean("<img src=\"/static/p.png\" alt=\"a\"><img src=\"https://cdn.makenot.work/b\" alt=\"b\">"); | |
| 215 | + | assert!(out.contains("/static/p.png")); | |
| 216 | + | assert!(out.contains("cdn.makenot.work/b")); | |
| 217 | + | } | |
| 218 | + | ||
| 219 | + | #[test] | |
| 220 | + | fn drops_javascript_href_and_records() { | |
| 221 | + | let (out, rej) = sanitize_html("<a href=\"javascript:alert(1)\">x</a>", &policy()); | |
| 222 | + | assert!(!out.to_lowercase().contains("javascript")); | |
| 223 | + | assert!(rej.iter().any(|r| matches!(r.kind, RejectionKind::DisallowedScheme))); | |
| 224 | + | } | |
| 225 | + | ||
| 226 | + | #[test] | |
| 227 | + | fn anchors_get_nofollow_ugc() { | |
| 228 | + | let out = clean("<a href=\"/alice\">me</a>"); | |
| 229 | + | assert!(out.contains("rel=\"nofollow ugc\"")); | |
| 230 | + | } | |
| 231 | + | ||
| 232 | + | #[test] | |
| 233 | + | fn drops_autoplay_and_loop_audio_attrs() { | |
| 234 | + | let out = clean("<audio src=\"/a.mp3\" controls loop autoplay muted></audio>"); | |
| 235 | + | assert!(out.contains("controls")); | |
| 236 | + | assert!(!out.contains("autoplay")); | |
| 237 | + | assert!(!out.contains("loop")); | |
| 238 | + | assert!(!out.contains("muted")); | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | #[test] | |
| 242 | + | fn drops_video_autoplay_keeps_loop() { | |
| 243 | + | let out = clean("<video src=\"/v.mp4\" controls loop autoplay></video>"); | |
| 244 | + | assert!(out.contains("loop")); | |
| 245 | + | assert!(!out.contains("autoplay")); | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | #[test] | |
| 249 | + | fn srcset_with_external_candidate_is_dropped() { | |
| 250 | + | let (out, rej) = sanitize_html( | |
| 251 | + | "<img srcset=\"/a.png 1x, https://evil.com/b.png 2x\" alt=\"a\">", | |
| 252 | + | &policy(), | |
| 253 | + | ); | |
| 254 | + | assert!(!out.contains("evil.com")); | |
| 255 | + | assert!(!out.contains("srcset")); | |
| 256 | + | assert!(!rej.is_empty()); | |
| 257 | + | } | |
| 258 | + | ||
| 259 | + | #[test] | |
| 260 | + | fn comments_stripped() { | |
| 261 | + | let out = clean("<p>a</p><!-- secret -->"); | |
| 262 | + | assert!(!out.contains("secret")); | |
| 263 | + | } | |
| 264 | + | ||
| 265 | + | #[test] | |
| 266 | + | fn idempotent() { | |
| 267 | + | let input = "<section><a href=\"/x\">l</a><img src=\"https://evil.com/y\"><script>z</script></section>"; | |
| 268 | + | let once = clean(input); | |
| 269 | + | let twice = clean(&once); | |
| 270 | + | assert_eq!(once, twice); | |
| 271 | + | } | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | #[cfg(test)] | |
| 275 | + | mod proptests { | |
| 276 | + | use super::*; | |
| 277 | + | use proptest::prelude::*; | |
| 278 | + | ||
| 279 | + | fn policy() -> UrlPolicy { | |
| 280 | + | UrlPolicy::new( | |
| 281 | + | "https://u.makenot.work/a/p", | |
| 282 | + | ["makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string()], | |
| 283 | + | ) | |
| 284 | + | .unwrap() | |
| 285 | + | } | |
| 286 | + | ||
| 287 | + | proptest! { | |
| 288 | + | // Arbitrary input must never panic, and no dangerous *element* may | |
| 289 | + | // survive. (We only assert on tags, not substrings: a real `<script` | |
| 290 | + | // can only appear as an element -- angle brackets in text are escaped.) | |
| 291 | + | #[test] | |
| 292 | + | fn never_panics_no_dangerous_tags(input in "\\PC{0,400}") { | |
| 293 | + | let (out, _rej) = sanitize_html(&input, &policy()); | |
| 294 | + | let low = out.to_lowercase(); | |
| 295 | + | for tag in ["<script", "<iframe", "<object", "<embed", "<form", | |
| 296 | + | "<style", "<svg", "<math", "<link", "<meta", "<base"] { | |
| 297 | + | prop_assert!(!low.contains(tag), "leaked {tag}: {out}"); | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | // Output is stable under re-sanitization (ammonia idempotency). | |
| 302 | + | #[test] | |
| 303 | + | fn idempotent_fuzz(input in "\\PC{0,400}") { | |
| 304 | + | let once = sanitize_html(&input, &policy()).0; | |
| 305 | + | let twice = sanitize_html(&once, &policy()).0; | |
| 306 | + | prop_assert_eq!(once, twice); | |
| 307 | + | } | |
| 308 | + | ||
| 309 | + | // A randomly-built external image src is always stripped. | |
| 310 | + | #[test] | |
| 311 | + | fn external_img_always_stripped(host in "[a-z]{3,10}", tld in "(com|net|io|xyz)") { | |
| 312 | + | let domain = format!("{host}.{tld}"); | |
| 313 | + | let html = format!("<img src=\"https://{domain}/p.png\" alt=\"x\">"); | |
| 314 | + | let out = sanitize_html(&html, &policy()).0; | |
| 315 | + | prop_assert!(!out.contains(&domain)); | |
| 316 | + | } | |
| 317 | + | } | |
| 318 | + | } |
| @@ -0,0 +1,117 @@ | |||
| 1 | + | //! Custom Pages sanitization (Phase 1 foundation). | |
| 2 | + | //! | |
| 3 | + | //! Creators author raw HTML and CSS for their profile and project pages. This | |
| 4 | + | //! module turns that input into safe, closed-system page content: no scripting, | |
| 5 | + | //! no off-platform references, and CSS that cannot escape the user canvas to | |
| 6 | + | //! touch platform chrome. See `plans/custom-pages.md` for the full design. | |
| 7 | + | //! | |
| 8 | + | //! Three layers, one gate: | |
| 9 | + | //! - [`url_filter`] — the single rule that every URL (HTML attribute or CSS | |
| 10 | + | //! `url()`) must resolve to MNW itself. | |
| 11 | + | //! - [`html_sanitizer`] — an `ammonia` allowlist (structure, text, media; no | |
| 12 | + | //! script/embed/form/inline-style). | |
| 13 | + | //! - [`css_sanitizer`] — a `lightningcss` pass that scopes all selectors to the | |
| 14 | + | //! canvas, filters at-rules, validates `url()`, and strips system-slot hiding. | |
| 15 | + | //! | |
| 16 | + | //! The render path (Phase 2) reads pre-sanitized output; sanitization is a | |
| 17 | + | //! write-time cost paid on save. | |
| 18 | + | ||
| 19 | + | mod css_sanitizer; | |
| 20 | + | mod html_sanitizer; | |
| 21 | + | mod url_filter; | |
| 22 | + | ||
| 23 | + | pub use css_sanitizer::{sanitize_css, sanitize_item_css}; | |
| 24 | + | pub use html_sanitizer::sanitize_html; | |
| 25 | + | pub use url_filter::UrlPolicy; | |
| 26 | + | ||
| 27 | + | /// Why a single reference was stripped. Surfaced in the editor's | |
| 28 | + | /// blocked-references panel -- the primary teaching surface for the | |
| 29 | + | /// closed-system rule. | |
| 30 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 31 | + | pub enum RejectionKind { | |
| 32 | + | /// URL resolved to an off-platform host. | |
| 33 | + | ExternalUrl, | |
| 34 | + | /// URL carried a non-https scheme (`data:`, `javascript:`, `mailto:`, ...). | |
| 35 | + | DisallowedScheme, | |
| 36 | + | /// URL could not be parsed. | |
| 37 | + | MalformedUrl, | |
| 38 | + | /// A CSS at-rule outside the allowlist (`@import`, `@namespace`, ...). | |
| 39 | + | BlockedAtRule, | |
| 40 | + | /// A dangerous CSS function (`expression()`). | |
| 41 | + | BlockedFunction, | |
| 42 | + | /// A property that would hide a non-removable system slot (`.mnw-*`). | |
| 43 | + | HidingProperty, | |
| 44 | + | /// A fast infinite animation (strobe guard). | |
| 45 | + | AnimationBudget, | |
| 46 | + | /// Stylesheet exceeded a complexity cap (DoS guard). | |
| 47 | + | ComplexityLimit, | |
| 48 | + | /// CSS that could not be parsed at all. | |
| 49 | + | MalformedCss, | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | /// One stripped reference, with enough context for the editor to point at it. | |
| 53 | + | #[derive(Debug, Clone, PartialEq, Eq)] | |
| 54 | + | pub struct Rejection { | |
| 55 | + | pub kind: RejectionKind, | |
| 56 | + | /// Human-readable origin, e.g. `"img src"`, `"css url()"`, `"@import"`. | |
| 57 | + | pub location: String, | |
| 58 | + | /// The value as the creator wrote it. | |
| 59 | + | pub original_value: String, | |
| 60 | + | /// One-line explanation shown to the creator. | |
| 61 | + | pub reason: String, | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | /// Maximum style rules in a sanitized sheet (quadratic-matching DoS guard). | |
| 65 | + | /// Far above any reasonable page. | |
| 66 | + | pub(crate) const MAX_RULES: usize = 5000; | |
| 67 | + | /// Maximum selectors across a sanitized sheet. | |
| 68 | + | pub(crate) const MAX_SELECTORS: usize = 10000; | |
| 69 | + | ||
| 70 | + | /// Sanitize a full custom page (HTML + CSS together). | |
| 71 | + | /// | |
| 72 | + | /// `owner_scope` is the id woven into the canvas selector | |
| 73 | + | /// `.user-canvas#uc-{owner_scope}` that all CSS is confined to -- pass the | |
| 74 | + | /// owner's UUID. Returns sanitized HTML, sanitized CSS, and every reference the | |
| 75 | + | /// sanitizer stripped (deduplicated only by being appended in order). | |
| 76 | + | pub fn sanitize_page( | |
| 77 | + | html: &str, | |
| 78 | + | css: &str, | |
| 79 | + | owner_scope: &str, | |
| 80 | + | policy: &UrlPolicy, | |
| 81 | + | ) -> (String, String, Vec<Rejection>) { | |
| 82 | + | let (clean_html, mut rejections) = sanitize_html(html, policy); | |
| 83 | + | let (clean_css, css_rejections) = sanitize_css(css, owner_scope, policy); | |
| 84 | + | rejections.extend(css_rejections); | |
| 85 | + | (clean_html, clean_css, rejections) | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | #[cfg(test)] | |
| 89 | + | mod tests { | |
| 90 | + | use super::*; | |
| 91 | + | ||
| 92 | + | fn policy() -> UrlPolicy { | |
| 93 | + | UrlPolicy::new( | |
| 94 | + | "https://u.makenot.work/alice/proj", | |
| 95 | + | ["makenot.work".to_string(), "u.makenot.work".to_string(), "cdn.makenot.work".to_string()], | |
| 96 | + | ) | |
| 97 | + | .unwrap() | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | #[test] | |
| 101 | + | fn page_sanitizes_both_and_collects_rejections() { | |
| 102 | + | let (html, css, rej) = sanitize_page( | |
| 103 | + | "<p>hi</p><script>evil()</script><img src=\"https://evil.com/x\">", | |
| 104 | + | "body { color: red } .x { background: url(https://evil.com/y) }", | |
| 105 | + | "11111111-1111-1111-1111-111111111111", | |
| 106 | + | &policy(), | |
| 107 | + | ); | |
| 108 | + | assert!(html.contains("hi")); | |
| 109 | + | assert!(!html.contains("evil")); | |
| 110 | + | // CSS is scoped to the canvas. | |
| 111 | + | assert!(css.contains(".user-canvas#uc-11111111-1111-1111-1111-111111111111")); | |
| 112 | + | // body got neutralized into the canvas; off-platform url stripped. | |
| 113 | + | assert!(!css.contains("evil.com")); | |
| 114 | + | // At least the two external refs were recorded. | |
| 115 | + | assert!(rej.len() >= 2, "expected rejections, got {rej:?}"); | |
| 116 | + | } | |
| 117 | + | } |
| @@ -0,0 +1,274 @@ | |||
| 1 | + | //! The single URL gate for custom pages. | |
| 2 | + | //! | |
| 3 | + | //! Every URL that appears in user-authored HTML or CSS -- `href`, `src`, | |
| 4 | + | //! `srcset`, CSS `url()`, `@font-face src` -- passes through | |
| 5 | + | //! [`resolve_internal_url`]. The rule is absolute: a custom page may reference | |
| 6 | + | //! only MNW itself. No external hosts, no non-https schemes, no `data:`/ | |
| 7 | + | //! `javascript:`/`mailto:`. This is the closed-system constraint that makes the | |
| 8 | + | //! feature safe (no tracking, no exfiltration, no surprise third-party loads) | |
| 9 | + | //! and durable (pages keep working because every asset lives on the platform). | |
| 10 | + | //! | |
| 11 | + | //! Validation is done by resolving each candidate against the page's base | |
| 12 | + | //! origin with the WHATWG-compliant [`url`] crate -- the same parsing a browser | |
| 13 | + | //! does -- and then checking the resolved scheme and host. Resolving (rather | |
| 14 | + | //! than string-matching) is what defeats the classic bypasses: protocol- | |
| 15 | + | //! relative `//evil.com`, backslash tricks `/\evil.com`, userinfo smuggling | |
| 16 | + | //! `https://makenot.work@evil.com`, and lookalike hosts `makenot.work.evil.com` | |
| 17 | + | //! all resolve to a host that fails the exact allowlist check. | |
| 18 | + | //! | |
| 19 | + | //! Accepted input is returned **unchanged** (trimmed): relative paths stay | |
| 20 | + | //! relative, fragments stay fragments. We only resolve to decide; we never | |
| 21 | + | //! rewrite a creator's URLs into absolute form. | |
| 22 | + | ||
| 23 | + | use url::Url; | |
| 24 | + | ||
| 25 | + | use super::{Rejection, RejectionKind}; | |
| 26 | + | ||
| 27 | + | /// What counts as "internal" for a given render context. | |
| 28 | + | /// | |
| 29 | + | /// Built once from [`crate::config::Config`] and threaded through every | |
| 30 | + | /// sanitizer call. `base` is the origin the page renders on (the `u.` host); | |
| 31 | + | /// `hosts` is the exact, case-insensitive allowlist of bare hostnames that | |
| 32 | + | /// resolve to MNW. | |
| 33 | + | #[derive(Debug, Clone)] | |
| 34 | + | pub struct UrlPolicy { | |
| 35 | + | /// Page base origin, e.g. `https://u.makenot.work/`. Must be an absolute | |
| 36 | + | /// `https` URL ending in `/` so relative joins resolve correctly. | |
| 37 | + | base: Url, | |
| 38 | + | /// Allowed bare hostnames, lowercased, exact match (no subdomain wildcards). | |
| 39 | + | hosts: Vec<String>, | |
| 40 | + | } | |
| 41 | + | ||
| 42 | + | impl UrlPolicy { | |
| 43 | + | /// Build a policy from a base origin and the set of internal hostnames. | |
| 44 | + | /// | |
| 45 | + | /// Panics only on a malformed `base` (a deployment misconfiguration, caught | |
| 46 | + | /// at startup, never on user input). Hostnames are lowercased here so the | |
| 47 | + | /// hot path does an ASCII-cheap compare. | |
| 48 | + | pub fn new(base: &str, hosts: impl IntoIterator<Item = String>) -> Result<Self, url::ParseError> { | |
| 49 | + | let base = Url::parse(base)?; | |
| 50 | + | Ok(Self { | |
| 51 | + | base, | |
| 52 | + | hosts: hosts.into_iter().map(|h| h.to_ascii_lowercase()).collect(), | |
| 53 | + | }) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | fn is_internal_host(&self, host: &str) -> bool { | |
| 57 | + | let host = host.to_ascii_lowercase(); | |
| 58 | + | self.hosts.iter().any(|h| *h == host) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | /// Validate one URL. On success returns the original (trimmed) string to embed | |
| 63 | + | /// as-is; on failure returns a [`Rejection`] describing why, for the editor's | |
| 64 | + | /// blocked-references panel. | |
| 65 | + | /// | |
| 66 | + | /// `location` is a human-readable origin tag (e.g. `"img src"`, `"css url()"`) | |
| 67 | + | /// carried through to the rejection so the editor can point at the offending | |
| 68 | + | /// reference. | |
| 69 | + | pub fn resolve_internal_url(raw: &str, policy: &UrlPolicy, location: &str) -> Result<String, Rejection> { | |
| 70 | + | let s = raw.trim(); | |
| 71 | + | ||
| 72 | + | let reject = |kind: RejectionKind, reason: &str| Rejection { | |
| 73 | + | kind, | |
| 74 | + | location: location.to_string(), | |
| 75 | + | original_value: s.to_string(), | |
| 76 | + | reason: reason.to_string(), | |
| 77 | + | }; | |
| 78 | + | ||
| 79 | + | if s.is_empty() { | |
| 80 | + | return Err(reject(RejectionKind::MalformedUrl, "empty URL")); | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // Resolve against the page base exactly as a browser would. This collapses | |
| 84 | + | // every relative / protocol-relative / scheme-carrying form into one | |
| 85 | + | // absolute URL whose scheme and host we can check. | |
| 86 | + | let resolved = match self_or_join(&policy.base, s) { | |
| 87 | + | Ok(u) => u, | |
| 88 | + | Err(_) => return Err(reject(RejectionKind::MalformedUrl, "could not parse URL")), | |
| 89 | + | }; | |
| 90 | + | ||
| 91 | + | // The base is https, so anything that stays relative or fragment-only is | |
| 92 | + | // still https here. A non-https scheme means the input carried its own | |
| 93 | + | // (`http:`, `data:`, `javascript:`, `blob:`, `mailto:`, `file:`, ...). | |
| 94 | + | if resolved.scheme() != "https" { | |
| 95 | + | return Err(reject( | |
| 96 | + | RejectionKind::DisallowedScheme, | |
| 97 | + | &format!("scheme `{}:` is not allowed; only on-platform https URLs and relative paths", resolved.scheme()), | |
| 98 | + | )); | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | match resolved.host_str() { | |
| 102 | + | Some(host) if policy.is_internal_host(host) => Ok(s.to_string()), | |
| 103 | + | Some(host) => Err(reject( | |
| 104 | + | RejectionKind::ExternalUrl, | |
| 105 | + | &format!("host `{host}` is off-platform; custom pages may reference only MNW"), | |
| 106 | + | )), | |
| 107 | + | None => Err(reject(RejectionKind::MalformedUrl, "URL has no host")), | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | /// Join a candidate onto the base. `Url::join` handles relative paths, | |
| 112 | + | /// fragments, protocol-relative, and absolute-with-scheme inputs uniformly. | |
| 113 | + | fn self_or_join(base: &Url, candidate: &str) -> Result<Url, url::ParseError> { | |
| 114 | + | base.join(candidate) | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | /// Validate a `srcset` value: a comma-separated list of `url [descriptor]` | |
| 118 | + | /// candidates. Every URL must pass; a single bad candidate rejects the whole | |
| 119 | + | /// attribute (we cannot partially trust a responsive image set). Returns the | |
| 120 | + | /// original string on success. | |
| 121 | + | pub fn resolve_srcset(raw: &str, policy: &UrlPolicy, location: &str) -> Result<String, Rejection> { | |
| 122 | + | for candidate in raw.split(',') { | |
| 123 | + | let candidate = candidate.trim(); | |
| 124 | + | if candidate.is_empty() { | |
| 125 | + | continue; | |
| 126 | + | } | |
| 127 | + | // `url descriptor` -- the URL is the first whitespace-delimited token. | |
| 128 | + | let url_part = candidate.split_whitespace().next().unwrap_or(""); | |
| 129 | + | resolve_internal_url(url_part, policy, location)?; | |
| 130 | + | } | |
| 131 | + | Ok(raw.trim().to_string()) | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | #[cfg(test)] | |
| 135 | + | mod tests { | |
| 136 | + | use super::*; | |
| 137 | + | ||
| 138 | + | fn policy() -> UrlPolicy { | |
| 139 | + | UrlPolicy::new( | |
| 140 | + | "https://u.makenot.work/alice/cool-project", | |
| 141 | + | [ | |
| 142 | + | "makenot.work".to_string(), | |
| 143 | + | "u.makenot.work".to_string(), | |
| 144 | + | "cdn.makenot.work".to_string(), | |
| 145 | + | ], | |
| 146 | + | ) | |
| 147 | + | .unwrap() | |
| 148 | + | } | |
| 149 | + | ||
| 150 | + | fn ok(raw: &str) -> bool { | |
| 151 | + | resolve_internal_url(raw, &policy(), "test").is_ok() | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | #[test] | |
| 155 | + | fn accepts_relative_paths_unchanged() { | |
| 156 | + | for p in ["/static/x.png", "./img.png", "../sibling/x.png", "deep/path/x.css"] { | |
| 157 | + | let out = resolve_internal_url(p, &policy(), "test").unwrap(); | |
| 158 | + | assert_eq!(out, p, "relative path must be kept as-is"); | |
| 159 | + | } | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | #[test] | |
| 163 | + | fn accepts_fragments_and_query() { | |
| 164 | + | assert_eq!(resolve_internal_url("#section", &policy(), "t").unwrap(), "#section"); | |
| 165 | + | assert!(ok("/search?q=hello")); | |
| 166 | + | assert!(ok("?page=2")); | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | #[test] | |
| 170 | + | fn accepts_internal_absolute_https() { | |
| 171 | + | assert!(ok("https://makenot.work/alice")); | |
| 172 | + | assert!(ok("https://u.makenot.work/alice/p")); | |
| 173 | + | assert!(ok("https://cdn.makenot.work/blob/abc")); | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | #[test] | |
| 177 | + | fn host_match_is_case_insensitive() { | |
| 178 | + | assert!(ok("https://MakeNot.Work/alice")); | |
| 179 | + | assert!(ok("https://CDN.MAKENOT.WORK/x")); | |
| 180 | + | } | |
| 181 | + | ||
| 182 | + | #[test] | |
| 183 | + | fn rejects_external_hosts() { | |
| 184 | + | for u in [ | |
| 185 | + | "https://evil.com/x", | |
| 186 | + | "https://example.org", | |
| 187 | + | "https://makenot.work.evil.com/x", | |
| 188 | + | "https://notmakenot.work/x", | |
| 189 | + | ] { | |
| 190 | + | assert!(!ok(u), "must reject external host: {u}"); | |
| 191 | + | } | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | #[test] | |
| 195 | + | fn rejects_protocol_relative() { | |
| 196 | + | // //evil.com inherits the base scheme but the host is off-platform. | |
| 197 | + | assert!(!ok("//evil.com/x")); | |
| 198 | + | // //cdn.makenot.work is technically internal -- allowed. | |
| 199 | + | assert!(ok("//cdn.makenot.work/x")); | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | #[test] | |
| 203 | + | fn rejects_backslash_tricks() { | |
| 204 | + | // WHATWG parsing treats backslashes as slashes; these become external. | |
| 205 | + | assert!(!ok("/\\evil.com/x")); | |
| 206 | + | assert!(!ok("\\\\evil.com\\x")); | |
| 207 | + | } | |
| 208 | + | ||
| 209 | + | #[test] | |
| 210 | + | fn rejects_userinfo_smuggling() { | |
| 211 | + | // The real host is evil.com; makenot.work is just userinfo. | |
| 212 | + | assert!(!ok("https://makenot.work@evil.com/x")); | |
| 213 | + | assert!(!ok("https://makenot.work:pw@evil.com/x")); | |
| 214 | + | } | |
| 215 | + | ||
| 216 | + | #[test] | |
| 217 | + | fn rejects_dangerous_schemes() { | |
| 218 | + | for u in [ | |
| 219 | + | "javascript:alert(1)", | |
| 220 | + | "data:text/html,<script>alert(1)</script>", | |
| 221 | + | "data:image/png;base64,AAAA", | |
| 222 | + | "blob:https://makenot.work/uuid", | |
| 223 | + | "file:///etc/passwd", | |
| 224 | + | "ftp://makenot.work/x", | |
| 225 | + | "mailto:me@maxj.phd", | |
| 226 | + | "http://makenot.work/x", | |
| 227 | + | ] { | |
| 228 | + | let r = resolve_internal_url(u, &policy(), "t"); | |
| 229 | + | assert!(r.is_err(), "must reject scheme in: {u}"); | |
| 230 | + | } | |
| 231 | + | } | |
| 232 | + | ||
| 233 | + | #[test] | |
| 234 | + | fn same_scheme_colon_form_resolves_on_platform() { | |
| 235 | + | // "https:evil.com" is a same-scheme *relative* reference under WHATWG: | |
| 236 | + | // it resolves to u.makenot.work/evil.com, not the host evil.com -- the | |
| 237 | + | // browser will interpret it identically. Accepted, and safe. | |
| 238 | + | let out = resolve_internal_url("https:evil.com", &policy(), "t").unwrap(); | |
| 239 | + | assert_eq!(out, "https:evil.com"); | |
| 240 | + | // The genuinely external forms still reject. | |
| 241 | + | assert!(!ok("https://evil.com")); | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | #[test] | |
| 245 | + | fn rejects_empty() { | |
| 246 | + | assert!(!ok("")); | |
| 247 | + | assert!(!ok(" ")); | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | #[test] | |
| 251 | + | fn rejects_percent_encoded_external_host() { | |
| 252 | + | // WHATWG host parsing percent-decodes; `%65vil.com` is `evil.com`. | |
| 253 | + | // Whether it decodes-and-rejects or fails to parse, it must not pass. | |
| 254 | + | assert!(!ok("https://%65vil.com/x")); | |
| 255 | + | assert!(!ok("https://e%76il.com/x")); | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | #[test] | |
| 259 | + | fn rejection_carries_location_and_value() { | |
| 260 | + | let r = resolve_internal_url("https://evil.com/x", &policy(), "img src").unwrap_err(); | |
| 261 | + | assert_eq!(r.location, "img src"); | |
| 262 | + | assert_eq!(r.original_value, "https://evil.com/x"); | |
| 263 | + | assert!(matches!(r.kind, RejectionKind::ExternalUrl)); | |
| 264 | + | } | |
| 265 | + | ||
| 266 | + | #[test] | |
| 267 | + | fn srcset_all_candidates_validated() { | |
| 268 | + | let good = "/a.png 1x, /b.png 2x"; | |
| 269 | + | assert_eq!(resolve_srcset(good, &policy(), "t").unwrap(), good); | |
| 270 | + | ||
| 271 | + | let bad = "/a.png 1x, https://evil.com/b.png 2x"; | |
| 272 | + | assert!(resolve_srcset(bad, &policy(), "t").is_err()); | |
| 273 | + | } | |
| 274 | + | } |
| @@ -0,0 +1,120 @@ | |||
| 1 | + | //! Custom-page drafts and live-source updates (Custom Pages, Phase 3). | |
| 2 | + | //! | |
| 3 | + | //! Drafts let a creator experiment without touching the live page. There is one | |
| 4 | + | //! draft per `(owner, page_kind, page)`; the editor upserts it on every | |
| 5 | + | //! keystroke (debounced) and the preview renders from it. Saving promotes the | |
| 6 | + | //! draft to the live `custom_html`/`custom_css` columns and deletes the draft. | |
| 7 | + | ||
| 8 | + | use sqlx::PgPool; | |
| 9 | + | use uuid::Uuid; | |
| 10 | + | ||
| 11 | + | use super::UserId; | |
| 12 | + | use crate::error::Result; | |
| 13 | + | ||
| 14 | + | /// `page_kind` for a profile draft. | |
| 15 | + | pub const KIND_USER: &str = "user"; | |
| 16 | + | /// `page_kind` for a project draft. | |
| 17 | + | pub const KIND_PROJECT: &str = "project"; | |
| 18 | + | ||
| 19 | + | /// A row from `custom_page_drafts`. | |
| 20 | + | #[derive(Debug, Clone, sqlx::FromRow)] | |
| 21 | + | pub struct CustomPageDraft { | |
| 22 | + | pub id: Uuid, | |
| 23 | + | pub owner_id: UserId, | |
| 24 | + | pub page_kind: String, | |
| 25 | + | pub page_id: Uuid, | |
| 26 | + | pub custom_html: String, | |
| 27 | + | pub custom_css: String, | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | /// Fetch a draft by its (capability) id. Used by the preview route. | |
| 31 | + | pub async fn get_draft(pool: &PgPool, id: Uuid) -> Result<Option<CustomPageDraft>> { | |
| 32 | + | let draft = sqlx::query_as::<_, CustomPageDraft>("SELECT * FROM custom_page_drafts WHERE id = $1") | |
| 33 | + | .bind(id) | |
| 34 | + | .fetch_optional(pool) | |
| 35 | + | .await?; | |
| 36 | + | Ok(draft) | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | /// Return the existing draft for this page, or create one seeded from the | |
| 40 | + | /// current live source. The seed only applies on first creation -- an existing | |
| 41 | + | /// in-progress draft is returned untouched so the creator resumes where they | |
| 42 | + | /// left off. | |
| 43 | + | pub async fn get_or_create_draft( | |
| 44 | + | pool: &PgPool, | |
| 45 | + | owner_id: UserId, | |
| 46 | + | page_kind: &str, | |
| 47 | + | page_id: Uuid, | |
| 48 | + | seed_html: &str, | |
| 49 | + | seed_css: &str, | |
| 50 | + | ) -> Result<CustomPageDraft> { | |
| 51 | + | let draft = sqlx::query_as::<_, CustomPageDraft>( | |
| 52 | + | r#" | |
| 53 | + | INSERT INTO custom_page_drafts (owner_id, page_kind, page_id, custom_html, custom_css) | |
| 54 | + | VALUES ($1, $2, $3, $4, $5) | |
| 55 | + | ON CONFLICT (owner_id, page_kind, page_id) | |
| 56 | + | DO UPDATE SET updated_at = custom_page_drafts.updated_at | |
| 57 | + | RETURNING * | |
| 58 | + | "#, | |
| 59 | + | ) | |
| 60 | + | .bind(owner_id) | |
| 61 | + | .bind(page_kind) | |
| 62 | + | .bind(page_id) | |
| 63 | + | .bind(seed_html) | |
| 64 | + | .bind(seed_css) | |
| 65 | + | .fetch_one(pool) | |
| 66 | + | .await?; | |
| 67 | + | Ok(draft) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | /// Write the draft's content (autosave). Upserts on the page key and returns the | |
| 71 | + | /// stored row (so the caller has the stable draft id). | |
| 72 | + | pub async fn upsert_draft( | |
| 73 | + | pool: &PgPool, | |
| 74 | + | owner_id: UserId, | |
| 75 | + | page_kind: &str, | |
| 76 | + | page_id: Uuid, | |
| 77 | + | custom_html: &str, | |
| 78 | + | custom_css: &str, | |
| 79 | + | ) -> Result<CustomPageDraft> { | |
| 80 | + | let draft = sqlx::query_as::<_, CustomPageDraft>( | |
| 81 | + | r#" | |
| 82 | + | INSERT INTO custom_page_drafts (owner_id, page_kind, page_id, custom_html, custom_css) | |
| 83 | + | VALUES ($1, $2, $3, $4, $5) | |
| 84 | + | ON CONFLICT (owner_id, page_kind, page_id) | |
| 85 | + | DO UPDATE SET custom_html = EXCLUDED.custom_html, | |
| 86 | + | custom_css = EXCLUDED.custom_css, | |
| 87 | + | updated_at = now() | |
| 88 | + | RETURNING * | |
| 89 | + | "#, | |
| 90 | + | ) | |
| 91 | + | .bind(owner_id) | |
| 92 | + | .bind(page_kind) | |
| 93 | + | .bind(page_id) | |
| 94 | + | .bind(custom_html) | |
| 95 | + | .bind(custom_css) | |
| 96 | + | .fetch_one(pool) | |
| 97 | + | .await?; | |
| 98 | + | Ok(draft) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | /// Delete a page's draft (after a successful save). | |
| 102 | + | pub async fn delete_draft(pool: &PgPool, owner_id: UserId, page_kind: &str, page_id: Uuid) -> Result<()> { | |
| 103 | + | sqlx::query("DELETE FROM custom_page_drafts WHERE owner_id = $1 AND page_kind = $2 AND page_id = $3") | |
| 104 | + | .bind(owner_id) | |
| 105 | + | .bind(page_kind) | |
| 106 | + | .bind(page_id) | |
| 107 | + | .execute(pool) | |
| 108 | + | .await?; | |
| 109 | + | Ok(()) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | /// Delete drafts older than the given number of days (scheduled cleanup). | |
| 113 | + | pub async fn delete_drafts_older_than(pool: &PgPool, days: i64) -> Result<u64> { | |
| 114 | + | let result = sqlx::query(&format!( | |
| 115 | + | "DELETE FROM custom_page_drafts WHERE created_at < now() - interval '{days} days'" | |
| 116 | + | )) | |
| 117 | + | .execute(pool) | |
| 118 | + | .await?; | |
| 119 | + | Ok(result.rows_affected()) | |
| 120 | + | } |
| @@ -11,6 +11,7 @@ mod models; | |||
| 11 | 11 | mod subscription_writer; | |
| 12 | 12 | pub mod users; | |
| 13 | 13 | pub(crate) mod projects; | |
| 14 | + | pub mod custom_pages; | |
| 14 | 15 | pub mod items; | |
| 15 | 16 | pub mod versions; | |
| 16 | 17 | pub(crate) mod chapters; |
| @@ -35,6 +35,8 @@ pub struct DbAdminReportRow { | |||
| 35 | 35 | pub target_title: String, | |
| 36 | 36 | pub target_slug_or_id: String, | |
| 37 | 37 | pub target_owner: String, | |
| 38 | + | pub target_custom_html: Option<String>, | |
| 39 | + | pub target_custom_css: Option<String>, | |
| 38 | 40 | pub report_type: super::super::ReportType, | |
| 39 | 41 | pub reason: String, | |
| 40 | 42 | pub status: super::super::ReportStatus, |
| @@ -51,6 +51,15 @@ pub struct DbProject { | |||
| 51 | 51 | /// Chosen built-in theme id for this project's public pages (and its items, | |
| 52 | 52 | /// which inherit it). `None` = the platform default. See `crate::theming`. | |
| 53 | 53 | pub theme_id: Option<String>, | |
| 54 | + | /// Creator-authored project-page HTML (original source, pre-sanitization). | |
| 55 | + | /// Empty string = default rendering. Item pages inherit this project's CSS | |
| 56 | + | /// but have no HTML of their own. Served from `u.makenot.work`. | |
| 57 | + | pub custom_html: String, | |
| 58 | + | /// Creator-authored project-page CSS (original source, pre-sanitization). | |
| 59 | + | /// Re-scoped onto this project's item pages at render time. | |
| 60 | + | pub custom_css: String, | |
| 61 | + | /// When the custom page was last saved (cache-key + moderation review). | |
| 62 | + | pub custom_pages_updated_at: Option<DateTime<Utc>>, | |
| 54 | 63 | } | |
| 55 | 64 | ||
| 56 | 65 | /// A git repository tracked on disk, optionally linked to a project. |
| @@ -197,6 +197,16 @@ pub struct DbUser { | |||
| 197 | 197 | /// Chosen built-in theme id for this creator's public profile page. `None` = | |
| 198 | 198 | /// the platform default. References a bundled theme; see `crate::theming`. | |
| 199 | 199 | pub theme_id: Option<String>, | |
| 200 | + | /// Creator-authored profile-page HTML (original source, pre-sanitization). | |
| 201 | + | /// Empty string = no customization, render the default profile. Served from | |
| 202 | + | /// `u.makenot.work`; see `crate::custom_pages`. | |
| 203 | + | pub custom_html: String, | |
| 204 | + | /// Creator-authored profile-page CSS (original source, pre-sanitization). | |
| 205 | + | pub custom_css: String, | |
| 206 | + | /// When the custom page was last saved (cache-key + moderation review). | |
| 207 | + | pub custom_pages_updated_at: Option<DateTime<Utc>>, | |
| 208 | + | /// Moderation kill switch: while true the editor is read-only. | |
| 209 | + | pub custom_pages_locked: bool, | |
| 200 | 210 | } | |
| 201 | 211 | ||
| 202 | 212 | impl DbUser { | |
| @@ -279,6 +289,10 @@ mod tests { | |||
| 279 | 289 | bio: None, | |
| 280 | 290 | avatar_url: None, | |
| 281 | 291 | theme_id: None, | |
| 292 | + | custom_html: String::new(), | |
| 293 | + | custom_css: String::new(), | |
| 294 | + | custom_pages_updated_at: None, | |
| 295 | + | custom_pages_locked: false, | |
| 282 | 296 | created_at: Utc::now(), | |
| 283 | 297 | updated_at: Utc::now(), | |
| 284 | 298 | stripe_account_id: account_id.map(|s| s.to_string()), |
| @@ -153,6 +153,51 @@ pub async fn update_project( | |||
| 153 | 153 | Ok(project) | |
| 154 | 154 | } | |
| 155 | 155 | ||
| 156 | + | /// Store a project's custom-page source (original, pre-sanitization), stamp | |
| 157 | + | /// `custom_pages_updated_at` (which also invalidates the edge caches of every | |
| 158 | + | /// item page that inherits this project's CSS), and bump the cache generation. | |
| 159 | + | /// Scoped to the owner so a non-owner can't write through this path. | |
| 160 | + | pub async fn update_project_custom_page( | |
| 161 | + | pool: &PgPool, | |
| 162 | + | id: ProjectId, | |
| 163 | + | user_id: UserId, | |
| 164 | + | custom_html: &str, | |
| 165 | + | custom_css: &str, | |
| 166 | + | ) -> Result<DbProject> { | |
| 167 | + | let project = sqlx::query_as::<_, DbProject>( | |
| 168 | + | r#" | |
| 169 | + | UPDATE projects | |
| 170 | + | SET custom_html = $3, | |
| 171 | + | custom_css = $4, | |
| 172 | + | custom_pages_updated_at = now(), | |
| 173 | + | cache_generation = cache_generation + 1 | |
| 174 | + | WHERE id = $1 AND user_id = $2 | |
| 175 | + | RETURNING * | |
| 176 | + | "#, | |
| 177 | + | ) | |
| 178 | + | .bind(id) | |
| 179 | + | .bind(user_id) | |
| 180 | + | .bind(custom_html) | |
| 181 | + | .bind(custom_css) | |
| 182 | + | .fetch_one(pool) | |
| 183 | + | .await?; | |
| 184 | + | Ok(project) | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | /// Clear a project's custom page back to the platform default. | |
| 188 | + | pub async fn reset_project_custom_page(pool: &PgPool, id: ProjectId, user_id: UserId) -> Result<()> { | |
| 189 | + | sqlx::query( | |
| 190 | + | "UPDATE projects SET custom_html = '', custom_css = '', \ | |
| 191 | + | custom_pages_updated_at = NULL, cache_generation = cache_generation + 1 \ | |
| 192 | + | WHERE id = $1 AND user_id = $2", | |
| 193 | + | ) | |
| 194 | + | .bind(id) | |
| 195 | + | .bind(user_id) | |
| 196 | + | .execute(pool) | |
| 197 | + | .await?; | |
| 198 | + | Ok(()) | |
| 199 | + | } | |
| 200 | + | ||
| 156 | 201 | /// Set or clear a project's category. | |
| 157 | 202 | #[tracing::instrument(skip_all)] | |
| 158 | 203 | pub async fn set_project_category( |
| @@ -40,6 +40,12 @@ pub async fn get_admin_reports( | |||
| 40 | 40 | CASE WHEN r.target_type = 'item' THEN iu.username END, | |
| 41 | 41 | '(unknown)' | |
| 42 | 42 | ) AS target_owner, | |
| 43 | + | -- Custom-page source that rendered for this target, so moderators | |
| 44 | + | -- see what was published. Items inherit their parent project's CSS. | |
| 45 | + | CASE WHEN r.target_type = 'project' THEN p.custom_html | |
| 46 | + | WHEN r.target_type = 'item' THEN ip.custom_html END AS target_custom_html, | |
| 47 | + | CASE WHEN r.target_type = 'project' THEN p.custom_css | |
| 48 | + | WHEN r.target_type = 'item' THEN ip.custom_css END AS target_custom_css, | |
| 43 | 49 | r.report_type, | |
| 44 | 50 | r.reason, | |
| 45 | 51 | r.status, |
| @@ -101,6 +101,46 @@ pub async fn update_user_profile( | |||
| 101 | 101 | Ok(user) | |
| 102 | 102 | } | |
| 103 | 103 | ||
| 104 | + | /// Store a user's custom profile-page source (the original, pre-sanitization), | |
| 105 | + | /// stamp `custom_pages_updated_at`, and bump the cache generation. The source is | |
| 106 | + | /// re-sanitized on render; see [`crate::custom_pages`]. | |
| 107 | + | pub async fn update_user_custom_page( | |
| 108 | + | pool: &PgPool, | |
| 109 | + | id: UserId, | |
| 110 | + | custom_html: &str, | |
| 111 | + | custom_css: &str, | |
| 112 | + | ) -> Result<DbUser> { | |
| 113 | + | let user = sqlx::query_as::<_, DbUser>( | |
| 114 | + | r#" | |
| 115 | + | UPDATE users | |
| 116 | + | SET custom_html = $2, | |
| 117 | + | custom_css = $3, | |
| 118 | + | custom_pages_updated_at = now(), | |
| 119 | + | cache_generation = cache_generation + 1 | |
| 120 | + | WHERE id = $1 | |
| 121 | + | RETURNING * | |
| 122 | + | "#, | |
| 123 | + | ) | |
| 124 | + | .bind(id) | |
| 125 | + | .bind(custom_html) | |
| 126 | + | .bind(custom_css) | |
| 127 | + | .fetch_one(pool) | |
| 128 | + | .await?; | |
| 129 | + | Ok(user) | |
| 130 | + | } | |
| 131 | + | ||
| 132 | + | /// Clear a user's custom profile page back to the platform default. | |
| 133 | + | pub async fn reset_user_custom_page(pool: &PgPool, id: UserId) -> Result<()> { | |
| 134 | + | sqlx::query( | |
| 135 | + | "UPDATE users SET custom_html = '', custom_css = '', \ | |
| 136 | + | custom_pages_updated_at = NULL, cache_generation = cache_generation + 1 WHERE id = $1", | |
| 137 | + | ) | |
| 138 | + | .bind(id) | |
| 139 | + | .execute(pool) | |
| 140 | + | .await?; | |
| 141 | + | Ok(()) | |
| 142 | + | } | |
| 143 | + | ||
| 104 | 144 | /// Set or clear a user's creator theme for their public profile. `None` clears | |
| 105 | 145 | /// to the platform default. The id is validated against the embedded registry | |
| 106 | 146 | /// before this call. | |
| @@ -656,6 +696,27 @@ pub async fn get_all_users( | |||
| 656 | 696 | .fetch_all(pool) | |
| 657 | 697 | .await? | |
| 658 | 698 | } | |
| 699 | + | // Creators with a custom page; most recently changed first, so | |
| 700 | + | // "recently changed" surfaces naturally at the top. | |
| 701 | + | Some("custom_pages") => { | |
| 702 | + | sqlx::query_as::<_, DbUser>( | |
| 703 | + | "SELECT * FROM users WHERE custom_html <> '' OR custom_css <> '' \ | |
| 704 | + | ORDER BY custom_pages_updated_at DESC NULLS LAST, created_at DESC LIMIT $1 OFFSET $2", | |
| 705 | + | ) | |
| 706 | + | .bind(limit) | |
| 707 | + | .bind(offset) | |
| 708 | + | .fetch_all(pool) | |
| 709 | + | .await? | |
| 710 | + | } | |
| 711 | + | Some("pages_locked") => { | |
| 712 | + | sqlx::query_as::<_, DbUser>( | |
| 713 | + | "SELECT * FROM users WHERE custom_pages_locked = true ORDER BY created_at DESC LIMIT $1 OFFSET $2", | |
| 714 | + | ) | |
| 715 | + | .bind(limit) | |
| 716 | + | .bind(offset) | |
| 717 | + | .fetch_all(pool) | |
| 718 | + | .await? | |
| 719 | + | } | |
| 659 | 720 | _ => { | |
| 660 | 721 | sqlx::query_as::<_, DbUser>( | |
| 661 | 722 | "SELECT * FROM users ORDER BY created_at DESC LIMIT $1 OFFSET $2", | |
| @@ -688,6 +749,18 @@ pub async fn count_users(pool: &PgPool, filter: Option<&str>) -> Result<i64> { | |||
| 688 | 749 | .fetch_one(pool) | |
| 689 | 750 | .await? | |
| 690 | 751 | } | |
| 752 | + | Some("custom_pages") => { | |
| 753 | + | sqlx::query_scalar::<_, i64>( | |
| 754 | + | "SELECT COUNT(*) FROM users WHERE custom_html <> '' OR custom_css <> ''", | |
| 755 | + | ) | |
| 756 | + | .fetch_one(pool) | |
| 757 | + | .await? | |
| 758 | + | } | |
| 759 | + | Some("pages_locked") => { | |
| 760 | + | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users WHERE custom_pages_locked = true") | |
| 761 | + | .fetch_one(pool) | |
| 762 | + | .await? | |
| 763 | + | } | |
| 691 | 764 | _ => { | |
| 692 | 765 | sqlx::query_scalar::<_, i64>("SELECT COUNT(*) FROM users") | |
| 693 | 766 | .fetch_one(pool) | |
| @@ -911,6 +984,18 @@ pub async fn set_upload_trusted(pool: &PgPool, user_id: UserId, trusted: bool) - | |||
| 911 | 984 | Ok(()) | |
| 912 | 985 | } | |
| 913 | 986 | ||
| 987 | + | /// Moderation kill switch for custom pages. While locked, the creator can't | |
| 988 | + | /// edit their custom pages and the live pages render the platform default. | |
| 989 | + | /// Reversible: unlocking restores the (preserved) custom source. | |
| 990 | + | pub async fn set_custom_pages_locked(pool: &PgPool, user_id: UserId, locked: bool) -> Result<()> { | |
| 991 | + | sqlx::query("UPDATE users SET custom_pages_locked = $2, cache_generation = cache_generation + 1 WHERE id = $1") | |
| 992 | + | .bind(user_id) | |
| 993 | + | .bind(locked) | |
| 994 | + | .execute(pool) | |
| 995 | + | .await?; | |
| 996 | + | Ok(()) | |
| 997 | + | } | |
| 998 | + | ||
| 914 | 999 | // ── Onboarding email drip ── | |
| 915 | 1000 | ||
| 916 | 1001 | /// Users who need the next onboarding email. Returns users at a given step |
| @@ -7,6 +7,7 @@ pub mod build_runner; | |||
| 7 | 7 | pub mod config; | |
| 8 | 8 | pub mod constants; | |
| 9 | 9 | pub mod csrf; | |
| 10 | + | pub mod custom_pages; | |
| 10 | 11 | pub mod db; | |
| 11 | 12 | pub mod email; | |
| 12 | 13 | pub mod error; | |
| @@ -237,6 +238,11 @@ pub fn build_app( | |||
| 237 | 238 | .layer(middleware::from_fn_with_state(state.clone(), metrics::idempotency_middleware)) | |
| 238 | 239 | .layer(session_layer) | |
| 239 | 240 | .layer(RequestBodyLimitLayer::new(1024 * 1024)) | |
| 241 | + | // Outermost: requests to the user-pages host (`u.makenot.work`) are | |
| 242 | + | // served custom pages here and short-circuit before the session and | |
| 243 | + | // access-gate layers, so that host stays cookieless and ungated. | |
| 244 | + | // Everything else (and `/static`) falls through to the normal app. | |
| 245 | + | .layer(middleware::from_fn_with_state(state.clone(), routes::user_pages::dispatch)) | |
| 240 | 246 | } | |
| 241 | 247 | ||
| 242 | 248 | /// Middleware that sets security headers on all responses. | |
| @@ -292,6 +298,10 @@ async fn security_headers_middleware( | |||
| 292 | 298 | (true, false) => format!(" {cdn}"), | |
| 293 | 299 | (true, true) => String::new(), | |
| 294 | 300 | }; | |
| 301 | + | // The custom-page editor embeds a live preview served from the | |
| 302 | + | // user-pages host, so that origin must be a permitted frame source. | |
| 303 | + | let scheme = if state.config.host_url.starts_with("https") { "https" } else { "http" }; | |
| 304 | + | let user_pages_origin = format!("{scheme}://{}", state.config.user_pages_host); | |
| 295 | 305 | let csp = format!( | |
| 296 | 306 | "default-src 'self'; \ | |
| 297 | 307 | script-src 'self' 'unsafe-inline' https://js.stripe.com; \ | |
| @@ -300,7 +310,7 @@ async fn security_headers_middleware( | |||
| 300 | 310 | font-src 'self'; \ | |
| 301 | 311 | connect-src 'self' https://api.stripe.com{storage_origins}; \ | |
| 302 | 312 | media-src 'self'{storage_origins}; \ | |
| 303 | - | frame-src 'self' https://js.stripe.com; \ | |
| 313 | + | frame-src 'self' https://js.stripe.com {user_pages_origin}; \ | |
| 304 | 314 | base-uri 'self'; \ | |
| 305 | 315 | form-action 'self'; \ | |
| 306 | 316 | frame-ancestors 'none'" |
| @@ -223,6 +223,35 @@ pub fn record_domain_cache_size(size: usize) { | |||
| 223 | 223 | gauge!("domain_cache_entries").set(size as f64); | |
| 224 | 224 | } | |
| 225 | 225 | ||
| 226 | + | /// Count of live custom pages (profiles and project pages with non-empty | |
| 227 | + | /// source). Cheap two-count query; refreshed on the same cadence as storage | |
| 228 | + | /// fill. Lets us watch custom-page adoption without scraping the DB by hand. | |
| 229 | + | #[tracing::instrument(skip_all)] | |
| 230 | + | pub async fn record_custom_pages_stats(pool: &sqlx::PgPool) { | |
| 231 | + | let users: Result<(i64,), _> = | |
| 232 | + | sqlx::query_as("SELECT count(*) FROM users WHERE custom_html <> '' OR custom_css <> ''") | |
| 233 | + | .fetch_one(pool) | |
| 234 | + | .await; | |
| 235 | + | let projects: Result<(i64,), _> = | |
| 236 | + | sqlx::query_as("SELECT count(*) FROM projects WHERE custom_html <> '' OR custom_css <> ''") | |
| 237 | + | .fetch_one(pool) | |
| 238 | + | .await; | |
| 239 | + | match (users, projects) { | |
| 240 | + | (Ok((u,)), Ok((p,))) => { | |
| 241 | + | gauge!("custom_pages_active", "kind" => "profile").set(u as f64); | |
| 242 | + | gauge!("custom_pages_active", "kind" => "project").set(p as f64); | |
| 243 | + | } | |
| 244 | + | _ => tracing::debug!("custom-pages stats query failed"), | |
| 245 | + | } | |
| 246 | + | } | |
| 247 | + | ||
| 248 | + | /// Increment the sanitizer-rejection counter for one stripped reference, keyed | |
| 249 | + | /// by kind (e.g. `external_url`, `blocked_at_rule`). Called at save time so the | |
| 250 | + | /// counts reflect what creators actually publish, not per-keystroke previews. | |
| 251 | + | pub fn record_sanitizer_rejection(kind: &'static str) { | |
| 252 | + | counter!("custom_pages_sanitizer_rejections_total", "kind" => kind).increment(1); | |
| 253 | + | } | |
| 254 | + | ||
| 226 | 255 | /// Axum middleware that implements idempotency keys for POST endpoints. | |
| 227 | 256 | /// | |
| 228 | 257 | /// If the request includes an `Idempotency-Key` header and the user is |