Skip to main content

max / makenotwork

v0.3.9: custom domains, git patches, route splits, git-auth auto-create Phase 14C (Custom Domains): migration 043, DNS TXT verification via Cloudflare DoH, Caddy on-demand TLS, DashMap domain cache, fallback handler, dashboard UI, item slug auto-generation, integration tests. Git patches (I5): migration 044, inbound email patch parsing via Postmark webhook, patch storage and display. Split 4 large route files into directory modules: admin/, git_issues/, stripe/webhook/, synckit/. Git SSH auto-create: pushing to a non-existent repo now auto-creates the bare repo on disk and registers it in the database, matching sourcehut behavior. Only works for authenticated owners pushing to their own namespace. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Author: Max J. <87768334+MaxJMath@users.noreply.github.com> · 2026-03-25 21:50 UTC
Commit: 5c47d573fb829a9b20428a6d949b26173e21976d
Parent: aa2ff97
103 files changed, +7022 insertions, -4646 deletions
@@ -3350,7 +3350,7 @@ dependencies = [
3350 3350
3351 3351 [[package]]
3352 3352 name = "makenotwork"
3353 - version = "0.3.8"
3353 + version = "0.3.9"
3354 3354 dependencies = [
3355 3355 "anyhow",
3356 3356 "argon2",
@@ -1,6 +1,6 @@
1 1 [package]
2 2 name = "makenotwork"
3 - version = "0.3.8"
3 + version = "0.3.9"
4 4 edition = "2024"
5 5 license-file = "../../LICENSE"
6 6
@@ -6,6 +6,16 @@
6 6 # Authenticated Origin Pulls: only Cloudflare can reach the origin.
7 7 # git.makenot.work redirects browser visits to the web UI.
8 8 # SSH clone uses ssh.makenot.work (proxy OFF in Cloudflare).
9 + #
10 + # Custom domains: on-demand TLS via Let's Encrypt (ACME HTTP-01).
11 + # The ask endpoint validates that the domain is verified before issuing a cert.
12 + # makenot.work subdomains remain protected by Cloudflare mTLS even with ports open.
13 +
14 + {
15 + on_demand_tls {
16 + ask http://localhost:3000/api/domains/caddy-ask
17 + }
18 + }
9 19
10 20 # Shared TLS config: Origin CA cert + Authenticated Origin Pulls (mTLS)
11 21 (cloudflare_tls) {
@@ -119,6 +129,36 @@ cdn.makenot.work {
119 129 }
120 130 }
121 131
132 + # maxj.phd TLS config: separate Origin CA cert + Authenticated Origin Pulls (mTLS)
133 + (maxjphd_tls) {
134 + tls /etc/caddy/maxj-phd-origin.pem /etc/caddy/maxj-phd-origin-key.pem {
135 + client_auth {
136 + mode require_and_verify
137 + trusted_ca_cert_file /etc/caddy/cloudflare-authenticated-origin-pull-ca.pem
138 + }
139 + }
140 + }
141 +
142 + # Static file downloads (audiofiles binaries, etc.)
143 + dl.maxj.phd {
144 + import maxjphd_tls
145 +
146 + root * /opt/downloads
147 + file_server browse
148 +
149 + header {
150 + X-Content-Type-Options "nosniff"
151 + Strict-Transport-Security "max-age=31536000; includeSubDomains"
152 + }
153 +
154 + encode gzip zstd
155 +
156 + log {
157 + output file /var/log/caddy/dl-maxjphd.log
158 + format json
159 + }
160 + }
161 +
122 162 # Redirect www to canonical domain
123 163 # Note: makenotwork.com and www.makenotwork.com redirects are handled by
124 164 # Cloudflare Redirect Rules (edge-level, no origin hit needed).
@@ -133,3 +173,33 @@ www.makenot.work {
133 173 import cloudflare_tls
134 174 redir https://makenot.work{uri} permanent
135 175 }
176 +
177 + # Custom domains — on-demand TLS via Let's Encrypt.
178 + # Caddy calls /api/domains/caddy-ask before issuing a cert for any domain.
179 + # makenot.work subdomains are unaffected (matched by explicit blocks above
180 + # which use Cloudflare Origin CA + mTLS).
181 + :443 {
182 + tls {
183 + on_demand
184 + }
185 +
186 + reverse_proxy localhost:3000
187 +
188 + header {
189 + X-Content-Type-Options "nosniff"
190 + Strict-Transport-Security "max-age=31536000; includeSubDomains"
191 + Referrer-Policy "strict-origin-when-cross-origin"
192 + }
193 +
194 + encode gzip zstd
195 +
196 + log {
197 + output file /var/log/caddy/custom-domains.log
198 + format json
199 + }
200 + }
201 +
202 + # HTTP catch-all — redirect to HTTPS (also needed for ACME HTTP-01 challenges)
203 + :80 {
204 + redir https://{host}{uri} permanent
205 + }
@@ -4,11 +4,13 @@
4 4 # Rules:
5 5 # - Allow all traffic on Tailscale interface (tailscale0)
6 6 # - Allow SSH (port 22) from anywhere (needed for git SSH access)
7 - # - Allow HTTP/HTTPS (80/443) only from Cloudflare IP ranges
7 + # - Allow HTTP/HTTPS (80/443) from anywhere (custom domains need direct access)
8 8 # - Drop everything else
9 9 #
10 - # Cloudflare IPs: https://www.cloudflare.com/ips-v4/
11 - # Run periodically or when Cloudflare publishes new ranges.
10 + # HTTP/HTTPS is open to all because custom domains bypass Cloudflare.
11 + # makenot.work subdomains remain protected by Caddy mTLS (Authenticated Origin Pulls):
12 + # requests without a valid Cloudflare client cert are rejected by Caddy before
13 + # reaching the application.
12 14
13 15 set -e
14 16
@@ -30,32 +32,14 @@ ufw allow in on tailscale0
30 32 # SSH — open from anywhere (git clone over SSH)
31 33 ufw allow 22/tcp
32 34
33 - # HTTP/HTTPS — Cloudflare only
34 - CLOUDFLARE_IPS=(
35 - 173.245.48.0/20
36 - 103.21.244.0/22
37 - 103.22.200.0/22
38 - 103.31.4.0/22
39 - 141.101.64.0/18
40 - 108.162.192.0/18
41 - 190.93.240.0/20
42 - 188.114.96.0/20
43 - 197.234.240.0/22
44 - 198.41.128.0/17
45 - 162.158.0.0/15
46 - 104.16.0.0/13
47 - 104.24.0.0/14
48 - 172.64.0.0/13
49 - 131.0.72.0/22
50 - )
51 -
52 - for ip in "${CLOUDFLARE_IPS[@]}"; do
53 - ufw allow from "$ip" to any port 80,443 proto tcp
54 - done
35 + # HTTP/HTTPS — open to all (custom domains need direct access)
36 + # makenot.work is still protected by Caddy mTLS (Authenticated Origin Pulls)
37 + ufw allow 80/tcp
38 + ufw allow 443/tcp
55 39
56 40 # Enable
57 41 ufw --force enable
58 42 ufw status verbose
59 43
60 44 echo ""
61 - echo "Firewall configured. SSH open, HTTP/HTTPS Cloudflare-only."
45 + echo "Firewall configured. SSH, HTTP, HTTPS open. makenot.work protected by Caddy mTLS."
@@ -0,0 +1,56 @@
1 + -- Custom domains for creator accounts + item slugs for pretty URLs.
2 +
3 + CREATE TABLE custom_domains (
4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5 + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
6 + domain TEXT NOT NULL,
7 + verified BOOLEAN NOT NULL DEFAULT false,
8 + verification_token TEXT NOT NULL,
9 + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
10 + verified_at TIMESTAMPTZ,
11 + CONSTRAINT uq_custom_domains_domain UNIQUE (domain)
12 + );
13 + CREATE INDEX idx_custom_domains_user ON custom_domains(user_id);
14 +
15 + -- Item slugs for pretty URLs on custom domains.
16 + ALTER TABLE items ADD COLUMN slug TEXT;
17 +
18 + -- Backfill existing items with slugs derived from titles.
19 + -- Handles collisions within the same project by appending a counter.
20 + DO $$
21 + DECLARE
22 + r RECORD;
23 + base_slug TEXT;
24 + candidate TEXT;
25 + counter INT;
26 + BEGIN
27 + FOR r IN SELECT id, project_id, title FROM items WHERE slug IS NULL ORDER BY created_at
28 + LOOP
29 + -- Generate base slug: lowercase, non-alphanum to hyphen, collapse, trim
30 + base_slug := lower(r.title);
31 + base_slug := regexp_replace(base_slug, '[^a-z0-9]+', '-', 'g');
32 + base_slug := regexp_replace(base_slug, '-+', '-', 'g');
33 + base_slug := trim(BOTH '-' FROM base_slug);
34 + IF length(base_slug) < 2 THEN
35 + base_slug := 'item';
36 + END IF;
37 +
38 + candidate := base_slug;
39 + counter := 2;
40 +
41 + -- Resolve collisions within the same project
42 + WHILE EXISTS (
43 + SELECT 1 FROM items
44 + WHERE project_id = r.project_id AND slug = candidate AND id != r.id
45 + ) LOOP
46 + candidate := base_slug || '-' || counter;
47 + counter := counter + 1;
48 + END LOOP;
49 +
50 + UPDATE items SET slug = candidate WHERE id = r.id;
51 + END LOOP;
52 + END $$;
53 +
54 + -- After backfill, make slug NOT NULL and add unique constraint per project.
55 + ALTER TABLE items ALTER COLUMN slug SET NOT NULL;
56 + CREATE UNIQUE INDEX idx_items_slug_project ON items(project_id, slug);
@@ -0,0 +1,12 @@
1 + -- Patch inbound email: map email Message-ID headers to MT thread IDs for multi-part patch threading.
2 +
3 + CREATE TABLE patch_message_ids (
4 + id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
5 + message_id TEXT NOT NULL UNIQUE,
6 + project_id UUID NOT NULL REFERENCES projects(id) ON DELETE CASCADE,
7 + thread_id UUID NOT NULL,
8 + created_at TIMESTAMPTZ NOT NULL DEFAULT now()
9 + );
10 +
11 + CREATE INDEX idx_patch_message_ids_message ON patch_message_ids(message_id);
12 + CREATE INDEX idx_patch_message_ids_project ON patch_message_ids(project_id);
@@ -111,9 +111,13 @@ impl FromRequestParts<crate::AppState> for AuthUser {
111 111 .unwrap_or(false);
112 112
113 113 if !cached {
114 - let result = db::sessions::touch_session(&state.db, tracking_id)
115 - .await
116 - .unwrap_or(db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false });
114 + let result = match db::sessions::touch_session(&state.db, tracking_id).await {
115 + Ok(r) => r,
116 + Err(e) => {
117 + tracing::warn!(error = ?e, "session touch failed, invalidating");
118 + db::sessions::TouchResult { valid: false, suspended: false, can_create_projects: false }
119 + }
120 + };
117 121 if !result.valid {
118 122 state.session_cache.remove(&tracking_id);
119 123 let _ = session.flush().await;
@@ -381,6 +385,7 @@ mod tests {
381 385 build_host_linux: None,
382 386 build_host_darwin: None,
383 387 cdn_base_url: None,
388 + postmark_inbound_webhook_token: None,
384 389 internal_shared_secret: None,
385 390 };
386 391 assert!(require_admin(&user, &config).is_ok());
@@ -439,6 +444,7 @@ mod tests {
439 444 build_host_linux: None,
440 445 build_host_darwin: None,
441 446 cdn_base_url: None,
447 + postmark_inbound_webhook_token: None,
442 448 internal_shared_secret: None,
443 449 };
444 450 assert!(require_admin(&user, &config).is_err());
@@ -722,20 +722,44 @@ async fn cmd_git_auth(pool: &PgPool, key_id_str: &str) -> anyhow::Result<()> {
722 722 .await?
723 723 .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
724 724
725 - let repo = db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, &repo_name)
726 - .await?
727 - .ok_or_else(|| anyhow::anyhow!("repository not found"))?;
725 + let repo = match db::git_repos::get_repo_by_user_and_name(pool, owner_user.id, &repo_name).await? {
726 + Some(repo) => repo,
727 + None => {
728 + // Auto-create on push if the authenticated user owns the namespace
729 + if !matches!(operation, GitOperation::ReceivePack) || user_id != owner_user.id {
730 + anyhow::bail!("repository not found");
731 + }
732 +
733 + let git_root = std::env::var("GIT_REPOS_PATH")
734 + .unwrap_or_else(|_| "/opt/git".to_string());
735 + let owner_dir = std::path::Path::new(&git_root).join(owner);
736 + let repo_dir = owner_dir.join(format!("{repo_name}.git"));
737 +
738 + std::fs::create_dir_all(&owner_dir)?;
739 + git2::Repository::init_bare(&repo_dir)?;
740 +
741 + // Fix ownership so the git user can write
742 + let status = std::process::Command::new("chown")
743 + .args(["-R", "git:git"])
744 + .arg(&repo_dir)
745 + .status()?;
746 + if !status.success() {
747 + anyhow::bail!("chown failed on {}", repo_dir.display());
748 + }
749 +
750 + eprintln!("Auto-created repository {}/{}", owner, repo_name);
751 + db::git_repos::create_repo(pool, owner_user.id, &repo_name).await?
752 + }
753 + };
728 754
729 755 // Permission check
730 756 match operation {
731 757 GitOperation::ReceivePack => {
732 - // Push: must be repo owner
733 758 if user_id != owner_user.id {
734 759 anyhow::bail!("permission denied: you do not have push access to {}/{}", owner, repo_name);
735 760 }
736 761 }
737 762 GitOperation::UploadPack | GitOperation::Archive => {
738 - // Clone/fetch: check visibility
739 763 if repo.visibility == "private" && user_id != owner_user.id {
740 764 anyhow::bail!("repository not found");
741 765 }
@@ -204,12 +204,9 @@ async fn run_build(state: &AppState, build: &DbBuild, config: &DbBuildConfig) {
204 204
205 205 // Record artifacts
206 206 for (target_os, arch, s3_key) in &artifact_keys {
207 - // Get file size from S3 (best-effort, use 0 if unavailable)
207 + // Get file size from S3 via HEAD request (best-effort, use 0 if unavailable)
208 208 let file_size = if let Some(s3) = state.synckit_s3.as_ref() {
209 - match s3.download_object(s3_key).await {
210 - Ok(data) => data.len() as i64,
211 - Err(_) => 0,
212 - }
209 + s3.object_size(s3_key).await.ok().flatten().unwrap_or(0)
213 210 } else {
214 211 0
215 212 };
@@ -56,6 +56,8 @@ pub struct Config {
56 56 /// Base URL for CDN-served downloads (e.g., "https://cdn.makenot.work").
57 57 /// When set, free content downloads are served via CDN instead of presigned S3 URLs.
58 58 pub cdn_base_url: Option<String>,
59 + /// Bearer token for authenticating Postmark inbound email webhook (optional).
60 + pub postmark_inbound_webhook_token: Option<String>,
59 61 /// Shared secret for HMAC-signed internal API requests to MT.
60 62 /// Must match `INTERNAL_SHARED_SECRET` on the MT instance.
61 63 pub internal_shared_secret: Option<String>,
@@ -173,6 +175,9 @@ impl Config {
173 175 // CDN base URL - optional, when unset all downloads use presigned S3 URLs
174 176 let cdn_base_url = std::env::var("CDN_BASE_URL").ok();
175 177
178 + // Postmark inbound email webhook token - optional, inbound endpoint returns 401 if unset
179 + let postmark_inbound_webhook_token = std::env::var("POSTMARK_INBOUND_WEBHOOK_TOKEN").ok();
180 +
176 181 // Internal shared secret for MT communication
177 182 let internal_shared_secret = std::env::var("INTERNAL_SHARED_SECRET").ok();
178 183
@@ -199,6 +204,7 @@ impl Config {
199 204 build_host_linux,
200 205 build_host_darwin,
201 206 cdn_base_url,
207 + postmark_inbound_webhook_token,
202 208 internal_shared_secret,
203 209 })
204 210 }
@@ -333,6 +339,7 @@ impl std::fmt::Debug for Config {
333 339 .field("build_host_linux", &self.build_host_linux)
334 340 .field("build_host_darwin", &self.build_host_darwin)
335 341 .field("cdn_base_url", &self.cdn_base_url)
342 + .field("postmark_inbound_webhook_token", &self.postmark_inbound_webhook_token.as_ref().map(|_| "[REDACTED]"))
336 343 .field("internal_shared_secret", &self.internal_shared_secret.as_ref().map(|_| "[REDACTED]"))
337 344 .finish()
338 345 }
@@ -401,6 +408,7 @@ mod tests {
401 408 build_host_linux: None,
402 409 build_host_darwin: None,
403 410 cdn_base_url: None,
411 + postmark_inbound_webhook_token: None,
404 412 internal_shared_secret: None,
405 413 };
406 414 let addr = config.socket_addr();
@@ -152,6 +152,16 @@ pub const BUILD_ALLOWED_TARGETS: &[&str] = &[
152 152 "darwin/aarch64",
153 153 ];
154 154
155 + // -- Streaming --
156 + pub const STREAMING_CACHE_MAX_SECS: u64 = 86400; // 24 hours max presigned URL lifetime
157 +
158 + // -- Date display formats --
159 + pub const DATE_FMT_SHORT: &str = "%b %d"; // "Mar 25"
160 + pub const DATE_FMT_FULL: &str = "%b %d, %Y"; // "Mar 25, 2026"
161 + pub const DATE_FMT_ISO: &str = "%Y-%m-%d"; // "2026-03-25"
162 + pub const DATE_FMT_DATETIME: &str = "%b %d, %Y %H:%M"; // "Mar 25, 2026 14:30"
163 + pub const DATE_FMT_DATETIME_UTC: &str = "%b %d, %Y %H:%M UTC"; // "Mar 25, 2026 14:30 UTC"
164 +
155 165 // -- String / buffer limits --
156 166 pub const USER_AGENT_MAX_LENGTH: usize = 512;
157 167 pub const SYNCKIT_MAX_KEY_ENVELOPE_BYTES: usize = 4096;
@@ -281,33 +281,33 @@ pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result<
281 281 let total: i64 = sqlx::query_scalar(
282 282 r#"
283 283 WITH version_bytes AS (
284 - SELECT COALESCE(SUM(v.file_size_bytes), 0) AS total
284 + SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0) AS total
285 285 FROM versions v
286 286 JOIN items i ON v.item_id = i.id
287 287 JOIN projects p ON i.project_id = p.id
288 288 WHERE p.user_id = $1 AND v.file_size_bytes IS NOT NULL
289 289 ),
290 290 insertion_bytes AS (
291 - SELECT COALESCE(SUM(ci.file_size), 0) AS total
291 + SELECT COALESCE(SUM(ci.file_size)::BIGINT, 0) AS total
292 292 FROM content_insertions ci
293 293 WHERE ci.user_id = $1
294 294 ),
295 295 audio_bytes AS (
296 - SELECT COALESCE(SUM(i.audio_file_size_bytes), 0) AS total
296 + SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0) AS total
297 297 FROM items i
298 298 JOIN projects p ON i.project_id = p.id
299 299 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
300 300 ),
301 301 cover_bytes AS (
302 - SELECT COALESCE(SUM(i.cover_file_size_bytes), 0) AS total
302 + SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0) AS total
303 303 FROM items i
304 304 JOIN projects p ON i.project_id = p.id
305 305 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
306 306 )
307 - SELECT (SELECT total FROM version_bytes)
307 + SELECT ((SELECT total FROM version_bytes)
308 308 + (SELECT total FROM insertion_bytes)
309 309 + (SELECT total FROM audio_bytes)
310 - + (SELECT total FROM cover_bytes) AS total
310 + + (SELECT total FROM cover_bytes))::BIGINT AS total
311 311 "#,
312 312 )
313 313 .bind(user_id)
@@ -329,7 +329,7 @@ pub async fn recalculate_storage_used(pool: &PgPool, user_id: UserId) -> Result<
329 329 pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<StorageBreakdown> {
330 330 let audio: i64 = sqlx::query_scalar(
331 331 r#"
332 - SELECT COALESCE(SUM(i.audio_file_size_bytes), 0)
332 + SELECT COALESCE(SUM(i.audio_file_size_bytes)::BIGINT, 0)
333 333 FROM items i JOIN projects p ON i.project_id = p.id
334 334 WHERE p.user_id = $1 AND i.audio_file_size_bytes IS NOT NULL
335 335 "#,
@@ -340,7 +340,7 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto
340 340
341 341 let cover: i64 = sqlx::query_scalar(
342 342 r#"
343 - SELECT COALESCE(SUM(i.cover_file_size_bytes), 0)
343 + SELECT COALESCE(SUM(i.cover_file_size_bytes)::BIGINT, 0)
344 344 FROM items i JOIN projects p ON i.project_id = p.id
345 345 WHERE p.user_id = $1 AND i.cover_file_size_bytes IS NOT NULL
346 346 "#,
@@ -351,7 +351,7 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto
351 351
352 352 let download: i64 = sqlx::query_scalar(
353 353 r#"
354 - SELECT COALESCE(SUM(v.file_size_bytes), 0)
354 + SELECT COALESCE(SUM(v.file_size_bytes)::BIGINT, 0)
355 355 FROM versions v
356 356 JOIN items i ON v.item_id = i.id
357 357 JOIN projects p ON i.project_id = p.id
@@ -363,7 +363,7 @@ pub async fn get_storage_breakdown(pool: &PgPool, user_id: UserId) -> Result<Sto
363 363 .await?;
364 364
365 365 let insertion: i64 = sqlx::query_scalar(
366 - "SELECT COALESCE(SUM(file_size), 0) FROM content_insertions WHERE user_id = $1",
366 + "SELECT COALESCE(SUM(file_size)::BIGINT, 0) FROM content_insertions WHERE user_id = $1",
367 367 )
368 368 .bind(user_id)
369 369 .fetch_one(pool)
@@ -0,0 +1,117 @@
1 + //! Custom domain CRUD queries.
2 +
3 + use sqlx::PgPool;
4 +
5 + use super::models::DbCustomDomain;
6 + use super::{CustomDomainId, UserId};
7 + use crate::error::{AppError, Result};
8 +
9 + /// Create a custom domain entry with a verification token.
10 + /// Enforces a 1-domain-per-user limit.
11 + pub async fn create_custom_domain(
12 + pool: &PgPool,
13 + user_id: UserId,
14 + domain: &str,
15 + verification_token: &str,
16 + ) -> Result<DbCustomDomain> {
17 + // Check 1-domain-per-user limit
18 + let existing = sqlx::query_scalar::<_, i64>(
19 + "SELECT COUNT(*) FROM custom_domains WHERE user_id = $1",
20 + )
21 + .bind(user_id)
22 + .fetch_one(pool)
23 + .await?;
24 +
25 + if existing > 0 {
26 + return Err(AppError::BadRequest(
27 + "You already have a custom domain configured. Remove it first to add a new one.".to_string(),
28 + ));
29 + }
30 +
31 + let row = sqlx::query_as::<_, DbCustomDomain>(
32 + r#"
33 + INSERT INTO custom_domains (user_id, domain, verification_token)
34 + VALUES ($1, $2, $3)
35 + RETURNING *
36 + "#,
37 + )
38 + .bind(user_id)
39 + .bind(domain)
40 + .bind(verification_token)
41 + .fetch_one(pool)
42 + .await?;
43 +
44 + Ok(row)
45 + }
46 +
47 + /// Get the custom domain for a user (at most one).
48 + pub async fn get_custom_domain_by_user(
49 + pool: &PgPool,
50 + user_id: UserId,
51 + ) -> Result<Option<DbCustomDomain>> {
52 + let row = sqlx::query_as::<_, DbCustomDomain>(
53 + "SELECT * FROM custom_domains WHERE user_id = $1",
54 + )
55 + .bind(user_id)
56 + .fetch_optional(pool)
57 + .await?;
58 +
59 + Ok(row)
60 + }
61 +
62 + /// Look up a verified domain by hostname (for routing).
63 + pub async fn get_verified_domain(
64 + pool: &PgPool,
65 + domain: &str,
66 + ) -> Result<Option<DbCustomDomain>> {
67 + let row = sqlx::query_as::<_, DbCustomDomain>(
68 + "SELECT * FROM custom_domains WHERE domain = $1 AND verified = true",
69 + )
70 + .bind(domain)
71 + .fetch_optional(pool)
72 + .await?;
73 +
74 + Ok(row)
75 + }
76 +
77 + /// Mark a domain as verified.
78 + pub async fn mark_domain_verified(pool: &PgPool, domain_id: CustomDomainId) -> Result<()> {
79 + sqlx::query("UPDATE custom_domains SET verified = true, verified_at = NOW() WHERE id = $1")
80 + .bind(domain_id)
81 + .execute(pool)
82 + .await?;
83 +
84 + Ok(())
85 + }
86 +
87 + /// Delete a custom domain (only if owned by the given user).
88 + pub async fn delete_custom_domain(
89 + pool: &PgPool,
90 + domain_id: CustomDomainId,
91 + user_id: UserId,
92 + ) -> Result<()> {
93 + let result = sqlx::query(
94 + "DELETE FROM custom_domains WHERE id = $1 AND user_id = $2",
95 + )
96 + .bind(domain_id)
97 + .bind(user_id)
98 + .execute(pool)
99 + .await?;
100 +
101 + if result.rows_affected() == 0 {
102 + return Err(AppError::NotFound);
103 + }
104 +
105 + Ok(())
106 + }
107 +
108 + /// Get all verified domains (for cache warm-up on startup).
109 + pub async fn get_all_verified_domains(pool: &PgPool) -> Result<Vec<DbCustomDomain>> {
110 + let rows = sqlx::query_as::<_, DbCustomDomain>(
111 + "SELECT * FROM custom_domains WHERE verified = true",
112 + )
113 + .fetch_all(pool)
114 + .await?;
115 +
116 + Ok(rows)
117 + }
@@ -465,22 +465,26 @@ impl CreatorTier {
465 465 #[serde(rename_all = "lowercase")]
466 466 pub enum ProjectType {
467 467 Blog,
468 + Book,
468 469 Podcast,
469 470 Course,
470 471 Music,
471 472 Software,
472 473 Art,
474 + Writing,
473 475 #[default]
474 476 General,
475 477 }
476 478
477 479 impl_str_enum!(ProjectType {
478 480 Blog => "blog",
481 + Book => "book",
479 482 Podcast => "podcast",
480 483 Course => "course",
481 484 Music => "music",
482 485 Software => "software",
483 486 Art => "art",
487 + Writing => "writing",
484 488 General => "general",
485 489 });
486 490
@@ -489,11 +493,13 @@ impl ProjectType {
489 493 pub fn label(&self) -> &'static str {
490 494 match self {
491 495 Self::Blog => "Blog",
496 + Self::Book => "Book",
492 497 Self::Podcast => "Podcast",
493 498 Self::Course => "Course",
494 499 Self::Music => "Music",
495 500 Self::Software => "Software",
496 501 Self::Art => "Art",
502 + Self::Writing => "Writing",
497 503 Self::General => "General",
498 504 }
499 505 }
@@ -502,11 +508,13 @@ impl ProjectType {
502 508 pub fn all() -> &'static [(&'static str, &'static str)] {
503 509 &[
504 510 ("blog", "Blog"),
511 + ("book", "Book"),
505 512 ("podcast", "Podcast"),
506 513 ("course", "Course"),
507 514 ("music", "Music"),
508 515 ("software", "Software"),
509 516 ("art", "Art"),
517 + ("writing", "Writing"),
510 518 ("general", "General"),
511 519 ]
512 520 }
@@ -749,9 +757,9 @@ mod tests {
749 757 #[test]
750 758 fn project_type_all() {
751 759 let all = ProjectType::all();
752 - assert_eq!(all.len(), 7);
760 + assert_eq!(all.len(), 9);
753 761 assert_eq!(all[0], ("blog", "Blog"));
754 - assert_eq!(all[6], ("general", "General"));
762 + assert_eq!(all[8], ("general", "General"));
755 763 }
756 764
757 765 #[test]
@@ -176,6 +176,7 @@ define_pg_uuid_id!(
176 176 BuildId,
177 177 MailingListId,
178 178 MailingListSubscriberId,
179 + CustomDomainId,
179 180 );
180 181
181 182 #[cfg(test)]
@@ -8,6 +8,9 @@ use super::{ItemId, ProjectId, UserId};
8 8 use crate::error::Result;
9 9
10 10 /// Insert a new item into a project and return the created row.
11 + ///
12 + /// Auto-generates a URL-safe slug from the title. If the slug collides with
13 + /// an existing item in the same project, appends a counter suffix.
11 14 pub async fn create_item(
12 15 pool: &PgPool,
13 16 project_id: ProjectId,
@@ -16,22 +19,73 @@ pub async fn create_item(
16 19 price_cents: i32,
17 20 item_type: ItemType,
18 21 ) -> Result<DbItem> {
19 - let item = sqlx::query_as::<_, DbItem>(
20 - r#"
21 - INSERT INTO items (project_id, title, description, price_cents, item_type)
22 - VALUES ($1, $2, $3, $4, $5)
23 - RETURNING *
24 - "#,
22 + let mut slug = crate::helpers::slugify(title);
23 +
24 + // Check for collision and append counter if needed
25 + if item_slug_exists(pool, project_id, &slug).await? {
26 + let base = slug.clone();
27 + let mut counter = 2u32;
28 + loop {
29 + slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
30 + if !item_slug_exists(pool, project_id, &slug).await? {
31 + break;
32 + }
33 + counter += 1;
34 + }
35 + }
36 +
37 + // Retry loop for TOCTOU race on slug uniqueness
38 + let base_slug = slug.clone();
39 + let mut suffix = 1u32;
40 + let item = loop {
41 + match sqlx::query_as::<_, DbItem>(
42 + r#"
43 + INSERT INTO items (project_id, title, description, price_cents, item_type, slug)
44 + VALUES ($1, $2, $3, $4, $5, $6)
45 + RETURNING *
46 + "#,
47 + )
48 + .bind(project_id)
49 + .bind(title)
50 + .bind(description)
51 + .bind(price_cents)
52 + .bind(item_type)
53 + .bind(&slug)
54 + .fetch_one(pool)
55 + .await
56 + {
57 + Ok(item) => break item,
58 + Err(sqlx::Error::Database(db_err))
59 + if db_err.code().as_deref() == Some("23505") && suffix < 100 =>
60 + {
61 + suffix += 1;
62 + slug = super::validated_types::Slug::from_trusted(
63 + format!("{}-{}", base_slug, suffix),
64 + );
65 + continue;
66 + }
67 + Err(e) => return Err(e.into()),
68 + }
69 + };
70 +
71 + Ok(item)
72 + }
73 +
74 + /// Check whether a slug already exists for a given project.
75 + async fn item_slug_exists<'e, E: sqlx::Executor<'e, Database = sqlx::Postgres>>(
76 + executor: E,
77 + project_id: ProjectId,
78 + slug: &super::validated_types::Slug,
79 + ) -> Result<bool> {
80 + let exists: bool = sqlx::query_scalar(
81 + "SELECT EXISTS(SELECT 1 FROM items WHERE project_id = $1 AND slug = $2)",
25 82 )
26 83 .bind(project_id)
27 - .bind(title)
28 - .bind(description)
29 - .bind(price_cents)
30 - .bind(item_type)
31 - .fetch_one(pool)
84 + .bind(slug)
85 + .fetch_one(executor)
32 86 .await?;
33 87
34 - Ok(item)
88 + Ok(exists)
35 89 }
36 90
37 91 /// Fetch an item by primary key. Returns `None` if not found.
@@ -547,6 +601,26 @@ pub async fn bulk_delete(
547 601 pub async fn duplicate_item(pool: &PgPool, source_id: ItemId) -> Result<DbItem> {
548 602 let mut tx = pool.begin().await?;
549 603
604 + // Generate a unique slug for the copy
605 + let source = sqlx::query_as::<_, DbItem>("SELECT * FROM items WHERE id = $1")
606 + .bind(source_id)
607 + .fetch_one(&mut *tx)
608 + .await?;
609 + let copy_title = format!("Copy of {}", &source.title);
610 + let copy_title: String = copy_title.chars().take(200).collect();
611 + let mut slug = crate::helpers::slugify(&copy_title);
612 + if item_slug_exists(&mut *tx, source.project_id, &slug).await? {
613 + let base = slug.clone();
614 + let mut counter = 2u32;
615 + loop {
616 + slug = super::validated_types::Slug::from_trusted(format!("{}-{}", base, counter));
617 + if !item_slug_exists(&mut *tx, source.project_id, &slug).await? {
618 + break;
619 + }
620 + counter += 1;
621 + }
622 + }
623 +
550 624 // Step 1: Clone item row
551 625 let new_item = sqlx::query_as::<_, DbItem>(
552 626 r#"
@@ -554,19 +628,20 @@ pub async fn duplicate_item(pool: &PgPool, source_id: ItemId) -> Result<DbItem>
554 628 project_id, title, description, price_cents, item_type, thumbnail_url,
555 629 sort_order, body, word_count, reading_time_minutes, duration_seconds,
556 630 episode_number, enable_license_keys, default_max_activations,
557 - pwyw_enabled, pwyw_min_cents, is_public
631 + pwyw_enabled, pwyw_min_cents, is_public, slug
558 632 )
559 633 SELECT
560 634 project_id, LEFT('Copy of ' || title, 200), description, price_cents,
561 635 item_type, thumbnail_url, sort_order, body, word_count,
562 636 reading_time_minutes, duration_seconds, episode_number,
563 637 enable_license_keys, default_max_activations, pwyw_enabled,
564 - pwyw_min_cents, false
638 + pwyw_min_cents, false, $2
565 639 FROM items WHERE id = $1
566 640 RETURNING *
567 641 "#,
568 642 )
569 643 .bind(source_id)
644 + .bind(&slug)
570 645 .fetch_one(&mut *tx)
571 646 .await?;
572 647
@@ -670,6 +745,23 @@ pub async fn update_item_cover_file_size(
670 745 Ok(())
671 746 }
672 747
748 + /// Fetch a public item by project ID and slug (for custom domain routing).
749 + pub async fn get_item_by_project_and_slug(
750 + pool: &PgPool,
751 + project_id: ProjectId,
752 + slug: &str,
753 + ) -> Result<Option<DbItem>> {
754 + let item = sqlx::query_as::<_, DbItem>(
755 + "SELECT * FROM items WHERE project_id = $1 AND slug = $2 AND is_public = true",
756 + )
757 + .bind(project_id)
758 + .bind(slug)
759 + .fetch_optional(pool)
760 + .await?;
761 +
762 + Ok(item)
763 + }
764 +
673 765 /// Hide all items for a user (set is_public = false). Used for post-grace enforcement.
674 766 /// Returns the number of items hidden.
675 767 pub async fn hide_all_items_for_user(pool: &PgPool, user_id: UserId) -> Result<u64> {
@@ -202,6 +202,8 @@ pub async fn deactivate_machine(
202 202 license_key_id: LicenseKeyId,
203 203 machine_id: &str,
204 204 ) -> Result<bool> {
205 + let mut tx = pool.begin().await?;
206 +
205 207 let result = sqlx::query(
206 208 r#"
207 209 UPDATE license_activations
@@ -211,7 +213,7 @@ pub async fn deactivate_machine(
211 213 )
212 214 .bind(license_key_id)
213 215 .bind(machine_id)
214 - .execute(pool)
216 + .execute(&mut *tx)
215 217 .await?;
216 218
217 219 if result.rows_affected() > 0 {
@@ -227,11 +229,13 @@ pub async fn deactivate_machine(
227 229 "#,
228 230 )
229 231 .bind(license_key_id)
230 - .execute(pool)
232 + .execute(&mut *tx)
231 233 .await?;
232 234
235 + tx.commit().await?;
233 236 Ok(true)
234 237 } else {
238 + tx.commit().await?;
235 239 Ok(false)
236 240 }
237 241 }
@@ -48,6 +48,8 @@ pub(crate) mod ota;
48 48 pub(crate) mod builds;
49 49 pub(crate) mod creator_tiers;
50 50 pub(crate) mod mailing_lists;
51 + pub mod custom_domains;
52 + pub mod patches;
51 53
52 54 pub use id_types::*;
53 55 pub use validated_types::*;
@@ -298,6 +298,8 @@ pub struct DbItem {
298 298 pub audio_file_size_bytes: Option<i64>,
299 299 /// Size of the cover image in bytes (populated on upload confirm).
300 300 pub cover_file_size_bytes: Option<i64>,
301 + /// URL-safe slug unique per project (for custom domain pretty URLs).
302 + pub slug: String,
301 303 }
302 304
303 305 /// Content-type-specific data extracted from a `DbItem`.
@@ -1631,6 +1633,20 @@ pub struct DbMailingListSubscriber {
1631 1633 pub subscribed_at: DateTime<Utc>,
1632 1634 }
1633 1635
1636 + // ── Custom Domain models ──
1637 +
1638 + /// A custom domain configured for a creator's profile.
1639 + #[derive(Debug, Clone, FromRow)]
1640 + pub struct DbCustomDomain {
1641 + pub id: CustomDomainId,
1642 + pub user_id: UserId,
1643 + pub domain: String,
1644 + pub verified: bool,
1645 + pub verification_token: String,
1646 + pub created_at: DateTime<Utc>,
1647 + pub verified_at: Option<DateTime<Utc>>,
1648 + }
1649 +
1634 1650 #[cfg(test)]
1635 1651 mod tests {
1636 1652 use super::*;
@@ -1959,6 +1975,7 @@ mod tests {
1959 1975 web_only: false,
1960 1976 audio_file_size_bytes: None,
1961 1977 cover_file_size_bytes: None,
1978 + slug: "test".to_string(),
1962 1979 }
1963 1980 }
1964 1981
@@ -0,0 +1,62 @@
1 + //! Patch inbound email: message-ID → MT thread mapping for multi-part patch threading.
2 +
3 + use sqlx::PgPool;
4 + use uuid::Uuid;
5 +
6 + use crate::error::Result;
7 +
8 + /// Store a mapping from an email Message-ID to an MT thread.
9 + #[tracing::instrument(skip_all)]
10 + pub async fn insert_patch_message_id(
11 + pool: &PgPool,
12 + message_id: &str,
13 + project_id: Uuid,
14 + thread_id: Uuid,
15 + ) -> Result<()> {
16 + sqlx::query(
17 + "INSERT INTO patch_message_ids (message_id, project_id, thread_id)
18 + VALUES ($1, $2, $3)
19 + ON CONFLICT (message_id) DO NOTHING",
20 + )
21 + .bind(message_id)
22 + .bind(project_id)
23 + .bind(thread_id)
24 + .execute(pool)
25 + .await?;
26 + Ok(())
27 + }
28 +
29 + /// Look up a thread by a single email Message-ID.
30 + #[tracing::instrument(skip_all)]
31 + #[allow(dead_code)]
32 + pub async fn get_thread_id_by_message_id(
33 + pool: &PgPool,
34 + message_id: &str,
35 + ) -> Result<Option<Uuid>> {
36 + let row: Option<(Uuid,)> = sqlx::query_as(
37 + "SELECT thread_id FROM patch_message_ids WHERE message_id = $1",
38 + )
39 + .bind(message_id)
40 + .fetch_optional(pool)
41 + .await?;
42 + Ok(row.map(|r| r.0))
43 + }
44 +
45 + /// Look up a thread by any of several message IDs (from In-Reply-To + References headers).
46 + /// Returns the first match found.
47 + #[tracing::instrument(skip_all)]
48 + pub async fn get_thread_id_by_any_message_id(
49 + pool: &PgPool,
50 + message_ids: &[&str],
51 + ) -> Result<Option<Uuid>> {
52 + if message_ids.is_empty() {
53 + return Ok(None);
54 + }
55 + let row: Option<(Uuid,)> = sqlx::query_as(
56 + "SELECT thread_id FROM patch_message_ids WHERE message_id = ANY($1) LIMIT 1",
57 + )
58 + .bind(message_ids)
59 + .fetch_optional(pool)
60 + .await?;
61 + Ok(row.map(|r| r.0))
62 + }