Skip to main content

max / makenotwork

server: Tier 0 creator theming + fold in Run 18 storage work Unified theming groundwork (shared with AF/GO via theme-common): - theme-common 0.4.0: to_css_vars/to_css_declarations/PRIMITIVE_VARS — the single TOML->CSS mapping for the primitive layer — plus parse_theme_str. - shared/themes/mnw-default.toml: today's parchment look as a portable 14-token palette. - style.css: split :root into a primitive layer (theme-common var names) and a semantic layer derived from it; existing brand tokens kept as derived aliases (lossless, no call-site churn). - Tier 0: creators pick a built-in theme per profile/project (items inherit the parent project). Migration 138 adds nullable theme_id to users+projects; src/theming.rs embeds the bundled themes at compile time and caches their rendered CSS; theme_css is injected into profile/project/item <head>; pickers in the profile and project settings tabs; PUT /api/users/me/theme and PUT /api/projects/{id}/theme (validated, project bumps cache_generation). Also folds in the outstanding Run 18 storage/upload lifecycle changes present in the working tree (S3DeleteAuthority threading, orphan-queue confirms).
Co-Authored-By
Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Author: Max Johnson <me@maxj.phd> · 2026-06-13 00:07 UTC
Signed with PGP, not checked
Commit: e2a838126548522360b6ce39460f4ecea99edcbe
Parent: 8b05698
55 files changed, +1178 insertions, -212 deletions
@@ -3761,6 +3761,25 @@
3761 3761 "png",
3762 3762 ]
3763 3763
3764 + [[package]]
3765 + name = "include_dir"
3766 + version = "0.7.4"
3767 + source = "registry+https://github.com/rust-lang/crates.io-index"
3768 + checksum = "923d117408f1e49d914f1a379a309cffe4f18c05cf4e3d12e613a15fc81bd0dd"
3769 + dependencies = [
3770 + "include_dir_macros",
3771 + ]
3772 +
3773 + [[package]]
3774 + name = "include_dir_macros"
3775 + version = "0.7.4"
3776 + source = "registry+https://github.com/rust-lang/crates.io-index"
3777 + checksum = "7cab85a7ed0bd5f0e76d93846e0147172bed2e2d3f859bcc33a8d9699cad1a75"
3778 + dependencies = [
3779 + "proc-macro2",
3780 + "quote",
3781 + ]
3782 +
3764 3783 [[package]]
3765 3784 name = "indexmap"
3766 3785 version = "2.14.0"
@@ -4214,6 +4233,7 @@
4214 4233 "hex",
4215 4234 "hmac 0.12.1",
4216 4235 "http-body-util",
4236 + "include_dir",
4217 4237 "infer",
4218 4238 "jsonwebtoken",
4219 4239 "log",
@@ -4238,6 +4258,7 @@
4238 4258 "syntect",
4239 4259 "tagtree",
4240 4260 "tempfile",
4261 + "theme-common",
4241 4262 "thiserror 2.0.18",
4242 4263 "tokio",
4243 4264 "tokio-stream",
@@ -7036,6 +7057,14 @@
7036 7057 source = "registry+https://github.com/rust-lang/crates.io-index"
7037 7058 checksum = "f18aa187839b2bdb1ad2fa35ead8c4c2976b64e4363c386d45ac0f7ee85c9233"
7038 7059
7060 + [[package]]
7061 + name = "theme-common"
7062 + version = "0.4.0"
7063 + dependencies = [
7064 + "serde",
7065 + "toml",
7066 + ]
7067 +
7039 7068 [[package]]
7040 7069 name = "thiserror"
7041 7070 version = "1.0.69"
@@ -116,6 +116,11 @@
116 116 # Tag standard
117 117 tagtree = { path = "../shared/tagtree" }
118 118
119 + # Shared theme palette (Tier 0 creator theming). Themes are embedded at compile
120 + # time from shared/themes/ via include_dir, so production needs no themes path.
121 + theme-common = { path = "../shared/theme-common" }
122 + include_dir = "0.7"
123 +
119 124 # Git source browser
120 125 git2 = { version = "0.20", features = ["vendored-libgit2"] }
121 126 syntect = { version = "5", default-features = false, features = ["default-syntaxes", "default-themes", "html", "regex-fancy"] }
@@ -34,6 +34,7 @@
34 34 pub mod storage;
35 35 pub mod synckit_auth;
36 36 pub mod templates;
37 + pub mod theming;
37 38 pub mod tier_prices;
38 39 pub mod types;
39 40 pub mod validation;
@@ -990,6 +990,7 @@
990 990 description: None,
991 991 project_type: db::ProjectType::General,
992 992 cover_image_url: None,
993 + theme_id: None,
993 994 is_public: true,
994 995 created_at: chrono::Utc::now(),
995 996 updated_at: chrono::Utc::now(),
@@ -211,6 +211,33 @@
211 211 /// One year with immutable directive — Cloudflare and browsers cache indefinitely.
212 212 pub const CACHE_CONTROL_IMMUTABLE: &str = "public, max-age=31536000, immutable";
213 213
214 + /// Capability proof required by every `StorageBackend` delete method.
215 + ///
216 + /// Direct S3 deletion is sealed off from route handlers: the delete methods
217 + /// take `&S3DeleteAuthority`, so the accidental `s3.delete_object(key)` from a
218 + /// handler no longer compiles. Route code must instead enqueue through
219 + /// `pending_s3_deletions` (e.g. `routes::storage::enqueue_s3_orphan`), whose
220 + /// worker applies the `is_s3_key_live` guard before deleting — closing the
221 + /// chronic where a handler blind-deleted a key a live row still referenced
222 + /// (Run #18 CHRONIC B′).
223 + ///
224 + /// Minting is `pub(crate)` and confined by convention to the durable-deletion
225 + /// paths — the scheduler deletion worker + cleanup (`scheduler/cleanup.rs`) and
226 + /// the malware-quarantine scan worker (`scanning/worker.rs`). The build-time
227 + /// guard test `routes_never_delete_s3_directly` fails if any file under
228 + /// `src/routes/` names a delete method or mints an authority, so the seal can't
229 + /// silently erode.
230 + pub struct S3DeleteAuthority(());
231 +
232 + impl S3DeleteAuthority {
233 + /// Mint a deletion authority. Restricted to the sanctioned durable-deletion
234 + /// paths; see the type docs. Route handlers cannot reach a sanctioned path,
235 + /// and the guard test enforces that they don't mint one anyway.
236 + pub(crate) fn new() -> Self {
237 + S3DeleteAuthority(())
238 + }
239 + }
240 +
214 241 /// Abstract storage backend — implemented by `S3Client` (production) and
215 242 /// `InMemoryStorage` (tests). Routes access storage through this trait.
216 243 #[async_trait::async_trait]
@@ -229,20 +256,23 @@
229 256 /// chunks directly.
230 257 async fn download_stream(&self, s3_key: &str) -> Result<s3_storage::ByteStream>;
231 258 async fn upload_object(&self, s3_key: &str, content_type: &str, data: Vec<u8>, cache_control: Option<&str>) -> Result<()>;
232 - async fn delete_object(&self, s3_key: &str) -> Result<()>;
259 + /// Delete an object. Requires an [`S3DeleteAuthority`] — route handlers
260 + /// cannot mint one, so they must enqueue through `pending_s3_deletions`
261 + /// instead of deleting directly (Run #18 CHRONIC B′).
262 + async fn delete_object(&self, auth: &S3DeleteAuthority, s3_key: &str) -> Result<()>;
233 263 /// Delete a batch of objects in a single S3 `DeleteObjects` request
234 264 /// (up to 1000 keys/call). Default loops `delete_object` so test backends
235 265 /// don't have to implement it, but production should override.
236 - async fn delete_objects(&self, keys: &[String]) -> Result<()> {
266 + async fn delete_objects(&self, auth: &S3DeleteAuthority, keys: &[String]) -> Result<()> {
237 267 for k in keys {
238 - if let Err(e) = self.delete_object(k).await {
268 + if let Err(e) = self.delete_object(auth, k).await {
239 269 tracing::warn!(key = %k, error = ?e, "delete_objects: per-key delete failed");
240 270 }
241 271 }
242 272 Ok(())
243 273 }
244 274 /// Delete all objects under a key prefix. Default logs a warning (no-op).
245 - async fn delete_prefix(&self, _prefix: &str) -> Result<()> {
275 + async fn delete_prefix(&self, _auth: &S3DeleteAuthority, _prefix: &str) -> Result<()> {
246 276 tracing::warn!("delete_prefix called on a storage backend that does not implement it");
247 277 Ok(())
248 278 }
@@ -540,7 +570,11 @@
540 570 /// behavior: we don't reject names containing `..`, we just guarantee the
541 571 /// output has no path separators. S3 keys are namespaced by user/item ID
542 572 /// upstream, so a flat literal here can't escape the user's prefix.
543 - fn sanitize_filename(filename: &str) -> String {
573 + ///
574 + /// `pub(crate)` so confirm handlers store a filename that matches the tail of
575 + /// the key `generate_media_key` produced, rather than re-deriving a weaker
576 + /// filter that drops the empty-basename fallback (Run #18 Storage B9).
577 + pub(crate) fn sanitize_filename(filename: &str) -> String {
544 578 let sanitized: String = filename
545 579 .chars()
546 580 .filter(|c| c.is_alphanumeric() || *c == '.' || *c == '-' || *c == '_')
@@ -678,15 +712,16 @@
678 712 self.upload_object(s3_key, content_type, data, cache_control).await
679 713 }
680 714
681 - async fn delete_object(&self, s3_key: &str) -> Result<()> {
715 + async fn delete_object(&self, _auth: &S3DeleteAuthority, s3_key: &str) -> Result<()> {
716 + // Authority proven by the caller; delegate to the inherent impl.
682 717 self.delete_object(s3_key).await
683 718 }
684 719
685 - async fn delete_objects(&self, keys: &[String]) -> Result<()> {
720 + async fn delete_objects(&self, _auth: &S3DeleteAuthority, keys: &[String]) -> Result<()> {
686 721 self.delete_objects(keys).await
687 722 }
688 723
689 - async fn delete_prefix(&self, prefix: &str) -> Result<()> {
724 + async fn delete_prefix(&self, _auth: &S3DeleteAuthority, prefix: &str) -> Result<()> {
690 725 self.inner.delete_prefix(prefix).await
691 726 .map_err(AppError::Storage)
692 727 }
@@ -1117,3 +1152,62 @@
1117 1152 assert!(key.ends_with("tutorial.mp4"));
1118 1153 }
1119 1154 }
1155 +
1156 + /// Build-time enforcement of CHRONIC B′ (Run #18): route handlers must never
1157 + /// delete S3 objects directly, nor mint an [`S3DeleteAuthority`]. Direct
1158 + /// deletion is for the sanctioned durable-deletion paths (`scheduler/cleanup.rs`,
1159 + /// `scanning/worker.rs`) only; handlers enqueue through `pending_s3_deletions`.
1160 + ///
1161 + /// The type system already makes the accidental `s3.delete_object(key)`
1162 + /// uncompilable (the delete methods require an authority handlers can't reach).
1163 + /// This test closes the deliberate-circumvention gap: it fails the build if any
1164 + /// file under `src/routes/` names a delete method or the authority type, so the
1165 + /// seal cannot silently erode in a future handler.
1166 + #[cfg(test)]
1167 + mod delete_seal_guard {
1168 + use std::path::Path;
1169 +
1170 + #[test]
1171 + fn routes_never_delete_s3_directly() {
1172 + let routes_dir = Path::new(env!("CARGO_MANIFEST_DIR")).join("src/routes");
1173 + let mut offenders = Vec::new();
1174 + walk(&routes_dir, &mut |path, contents| {
1175 + for (i, line) in contents.lines().enumerate() {
1176 + // Skip comment/doc lines (they legitimately mention the API).
1177 + if line.trim_start().starts_with("//") {
1178 + continue;
1179 + }
1180 + if line.contains(".delete_object(")
1181 + || line.contains(".delete_objects(")
1182 + || line.contains(".delete_prefix(")
1183 + || line.contains("S3DeleteAuthority")
1184 + {
1185 + offenders.push(format!("{}:{}: {}", path.display(), i + 1, line.trim()));
1186 + }
1187 + }
1188 + });
1189 + assert!(
1190 + offenders.is_empty(),
1191 + "CHRONIC B' seal violated — route code must enqueue via \
1192 + routes::storage::enqueue_s3_orphan, never delete S3 directly or mint an \
1193 + S3DeleteAuthority. Offending lines:\n{}",
1194 + offenders.join("\n")
1195 + );
1196 + }
1197 +
1198 + fn walk(dir: &Path, f: &mut impl FnMut(&Path, &str)) {
1199 + let Ok(entries) = std::fs::read_dir(dir) else {
1200 + return;
1201 + };
1202 + for entry in entries.flatten() {
1203 + let path = entry.path();
1204 + if path.is_dir() {
1205 + walk(&path, f);
1206 + } else if path.extension().is_some_and(|e| e == "rs")
1207 + && let Ok(contents) = std::fs::read_to_string(&path)
1208 + {
1209 + f(&path, &contents);
1210 + }
1211 + }
1212 + }
1213 + }