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
Signed with PGP, not checked
Commit: 2a69968baceba9cb4ed70a5b641025be181bb082
Parent: 4847697
50 files changed, +3778 insertions, -40 deletions
M server/Cargo.lock +159 -39
@@ -45,6 +45,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
1649 1650 "heck",
1650 1651 "proc-macro2",
1651 1652 "quote",
1652 - "syn",
1653 + "syn 2.0.117",
1653 1654 ]
1654 1655
1655 1656 [[package]]
@@ -1734,6 +1735,26 @@
1734 1735 source = "registry+https://github.com/rust-lang/crates.io-index"
1735 1736 checksum = "a6ef517f0926dd24a1582492c791b6a4818a4d94e789a334894aa15b0d12f55c"
1736 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 +
1737 1758 [[package]]
1738 1759 name = "const_panic"
1739 1760 version = "0.2.15"
@@ -1755,6 +1776,15 @@
1755 1776 source = "registry+https://github.com/rust-lang/crates.io-index"
1756 1777 checksum = "3d52eff69cd5e647efe296129160853a42795992097e8af39800e1060caeea9b"
1757 1778
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 +
1758 1788 [[package]]
1759 1789 name = "cookie"
1760 1790 version = "0.18.1"
@@ -2134,6 +2164,19 @@
2134 2164 "x509-certificate",
2135 2165 ]
2136 2166
2167 + [[package]]
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 +
2137 2180 [[package]]
2138 2181 name = "cssparser"
2139 2182 version = "0.35.0"
@@ -2147,6 +2190,15 @@
2147 2190 "smallvec",
2148 2191 ]
2149 2192
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 +
2150 2202 [[package]]
2151 2203 name = "cssparser-macros"
2152 2204 version = "0.6.1"
@@ -2154,7 +2206,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
2970 3022 dependencies = [
2971 3023 "proc-macro2",
2972 3024 "quote",
2973 - "syn",
3025 + "syn 2.0.117",
2974 3026 ]
2975 3027
2976 3028 [[package]]
@@ -3863,6 +3915,15 @@
3863 3915 source = "registry+https://github.com/rust-lang/crates.io-index"
3864 3916 checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
3865 3917
3918 + [[package]]
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 +
3866 3927 [[package]]
3867 3928 name = "itertools"
3868 3929 version = "0.13.0"
@@ -3908,7 +3969,7 @@
3908 3969 dependencies = [
3909 3970 "proc-macro2",
3910 3971 "quote",
3911 - "syn",
3972 + "syn 2.0.117",
3912 3973 ]
3913 3974
3914 3975 [[package]]
@@ -4069,6 +4130,41 @@
4069 4130 "vcpkg",
4070 4131 ]
4071 4132
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 +
4072 4168 [[package]]
4073 4169 name = "linux-raw-sys"
4074 4170 version = "0.4.15"
@@ -4131,7 +4227,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
4765 4863 dependencies = [
4766 4864 "proc-macro2",
4767 4865 "quote",
4768 - "syn",
4866 + "syn 2.0.117",
4769 4867 ]
4770 4868
4771 4869 [[package]]
@@ -4866,6 +4964,22 @@
4866 4964 "sha2 0.10.9",
4867 4965 ]
4868 4966
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 +
4869 4983 [[package]]
4870 4984 name = "parking"
4871 4985 version = "2.2.1"
@@ -4918,6 +5032,12 @@
4918 5032 source = "registry+https://github.com/rust-lang/crates.io-index"
4919 5033 checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
4920 5034
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 +
4921 5041 [[package]]
4922 5042 name = "pbkdf2"
4923 5043 version = "0.12.2"
@@ -4948,7 +5068,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
6923 7043 "heck",
6924 7044 "proc-macro2",
6925 7045 "quote",
6926 - "syn",
7046 + "syn 2.0.117",
6927 7047 ]
6928 7048
6929 7049 [[package]]
@@ -6932,6 +7052,17 @@
6932 7052 source = "registry+https://github.com/rust-lang/crates.io-index"
6933 7053 checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292"
Lines truncated
@@ -155,6 +155,8 @@
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"
@@ -687,6 +687,7 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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 @@
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,
@@ -7,6 +7,7 @@
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 @@
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 @@
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 @@
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 @@
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
@@ -165,6 +165,7 @@
165 165 };
166 166 if should_refresh {
167 167 crate::metrics::record_storage_fill_stats(&state.db).await;
168 + crate::metrics::record_custom_pages_stats(&state.db).await;
168 169 }
169 170
170 171 // Log status changes. Skip the bootstrap None->Operational transition
@@ -1003,6 +1003,9 @@
1003 1003 license_verification_enabled: false,
1004 1004 ai_tier: db::AiTier::Handmade,
1005 1005 ai_disclosure: None,
1006 + custom_html: String::new(),
1007 + custom_css: String::new(),
1008 + custom_pages_updated_at: None,
1006 1009 }
1007 1010 }
1008 1011