max / makenotwork
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(©_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 | + | } |