Skip to main content

max / makenotwork

server: creator custom pages (HTML/CSS) on u.makenot.work Sanitized, closed-system page customization for creator profiles and project pages, served from an isolated cookieless subdomain. - Sanitizer (src/custom_pages/): ammonia HTML allowlist, lightningcss CSS scoped to the user canvas, one on-platform URL gate; table-driven + proptest coverage. Migration 139 adds the source columns + custom_page_drafts. - Rendering (src/routes/user_pages.rs): host-dispatch middleware serves the u. host with a strict CSP and no session/cookies; item pages inherit their parent project's CSS re-scoped to the item canvas. - Editor (src/routes/pages/dashboard/custom_page.rs): split-pane HTML/CSS editor with debounced draft autosave, live preview iframe, and a blocked-references panel; profile + per-project, owner-scoped. - Moderation + ops: per-creator lock kill switch (admin lock/unlock + audit, user-list filters, report-payload inclusion), adoption gauge + sanitizer rejection counters, 30-day draft cleanup, built-in pattern primitives, and a public guide. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 17:14 UTC
Commit: 2a69968baceba9cb4ed70a5b641025be181bb082
Parent: 4847697
50 files changed, +3568 insertions, -40 deletions
M server/Cargo.lock +159 -39
@@ -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