max / makenotwork
4 files changed,
+147 insertions,
-114 deletions
| @@ -6,8 +6,9 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | ||
| 9 | - | use crate::{db, mt_client, AppState}; | |
| 9 | + | use crate::{background::BackgroundTx, config::Config, db, email::EmailClient, mt_client, Integrations}; | |
| 10 | 10 | use crate::db::{DbGitRepo, DbIssue}; | |
| 11 | + | use sqlx::PgPool; | |
| 11 | 12 | ||
| 12 | 13 | use super::{verify_token, HandlerOutcome, PostmarkInboundPayload}; | |
| 13 | 14 | ||
| @@ -18,16 +19,20 @@ use super::{verify_token, HandlerOutcome, PostmarkInboundPayload}; | |||
| 18 | 19 | /// - `@reply.makenot.work` -> reply to existing issue: `issue+{id}.{uid}.{sig}@reply.makenot.work` | |
| 19 | 20 | #[tracing::instrument(skip_all, name = "postmark::inbound_issues")] | |
| 20 | 21 | pub(super) async fn postmark_inbound_issues( | |
| 21 | - | State(state): State<AppState>, | |
| 22 | + | State(db): State<PgPool>, | |
| 23 | + | State(bg): State<BackgroundTx>, | |
| 24 | + | State(email): State<EmailClient>, | |
| 25 | + | State(integrations): State<Integrations>, | |
| 26 | + | State(config): State<Config>, | |
| 22 | 27 | headers: HeaderMap, | |
| 23 | 28 | Json(payload): Json<PostmarkInboundPayload>, | |
| 24 | 29 | ) -> HandlerOutcome { | |
| 25 | 30 | // 1. Auth — verify bearer token | |
| 26 | - | let token_ok = state.config.email_webhooks.inbound_webhook_token.as_deref() | |
| 31 | + | let token_ok = config.email_webhooks.inbound_webhook_token.as_deref() | |
| 27 | 32 | .is_some_and(|t| verify_token(&headers, t)); | |
| 28 | 33 | ||
| 29 | 34 | if !token_ok { | |
| 30 | - | if state.config.email_webhooks.inbound_webhook_token.is_none() { | |
| 35 | + | if config.email_webhooks.inbound_webhook_token.is_none() { | |
| 31 | 36 | tracing::warn!("Postmark inbound-issues received but no token configured"); | |
| 32 | 37 | } else { | |
| 33 | 38 | tracing::warn!("Postmark inbound-issues: invalid bearer token"); | |
| @@ -37,9 +42,9 @@ pub(super) async fn postmark_inbound_issues( | |||
| 37 | 42 | ||
| 38 | 43 | // 2. Route by domain | |
| 39 | 44 | if let Some((owner, repo)) = extract_issue_address(&payload.to) { | |
| 40 | - | handle_new_issue(&state, &payload, &owner, &repo).await | |
| 45 | + | handle_new_issue(&db, &bg, &email, &integrations, &config, &payload, &owner, &repo).await | |
| 41 | 46 | } else if let Some(local) = extract_reply_local(&payload.to) { | |
| 42 | - | handle_issue_reply(&state, &payload, &local).await | |
| 47 | + | handle_issue_reply(&db, &bg, &email, &integrations, &config, &payload, &local).await | |
| 43 | 48 | } else { | |
| 44 | 49 | tracing::debug!(to = %payload.to, "inbound-issues: unrecognized To address"); | |
| 45 | 50 | HandlerOutcome::Terminal(StatusCode::OK) | |
| @@ -47,8 +52,13 @@ pub(super) async fn postmark_inbound_issues( | |||
| 47 | 52 | } | |
| 48 | 53 | ||
| 49 | 54 | /// Handle a new issue submitted via `{owner}+{repo}@issues.makenot.work`. | |
| 55 | + | #[allow(clippy::too_many_arguments)] | |
| 50 | 56 | async fn handle_new_issue( | |
| 51 | - | state: &AppState, | |
| 57 | + | db: &PgPool, | |
| 58 | + | bg: &BackgroundTx, | |
| 59 | + | email: &EmailClient, | |
| 60 | + | integrations: &Integrations, | |
| 61 | + | config: &Config, | |
| 52 | 62 | payload: &PostmarkInboundPayload, | |
| 53 | 63 | owner: &str, | |
| 54 | 64 | repo_name: &str, | |
| @@ -64,13 +74,13 @@ async fn handle_new_issue( | |||
| 64 | 74 | // Reject a spoofed `From` before attributing the issue to that account | |
| 65 | 75 | // (Run 13 sender-spoofing): require SPF/DKIM alignment with the From domain. | |
| 66 | 76 | if !super::inbound_sender_trusted( | |
| 67 | - | state.config.email_webhooks.enforce_sender_auth, | |
| 77 | + | config.email_webhooks.enforce_sender_auth, | |
| 68 | 78 | &payload.from_full.email, | |
| 69 | 79 | &payload.headers, | |
| 70 | 80 | ) { | |
| 71 | 81 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 72 | 82 | } | |
| 73 | - | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 83 | + | let sender = match db::users::get_user_by_email(db, &sender_email).await { | |
| 74 | 84 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 75 | 85 | Ok(Some(u)) if !u.email_verified => { | |
| 76 | 86 | tracing::info!(email = %sender_email, "inbound-issues: sender email not verified"); | |
| @@ -98,7 +108,7 @@ async fn handle_new_issue( | |||
| 98 | 108 | } | |
| 99 | 109 | }; | |
| 100 | 110 | let owner_user = match db::users::get_user_by_username( | |
| 101 | - | &state.db, | |
| 111 | + | db, | |
| 102 | 112 | &owner_username, | |
| 103 | 113 | ).await { | |
| 104 | 114 | Ok(Some(u)) => u, | |
| @@ -111,7 +121,7 @@ async fn handle_new_issue( | |||
| 111 | 121 | } | |
| 112 | 122 | }; | |
| 113 | 123 | ||
| 114 | - | let repo = match db::git_repos::get_repo_by_user_and_name(&state.db, owner_user.id, repo_name).await { | |
| 124 | + | let repo = match db::git_repos::get_repo_by_user_and_name(db, owner_user.id, repo_name).await { | |
| 115 | 125 | Ok(Some(r)) => r, | |
| 116 | 126 | Ok(None) => { | |
| 117 | 127 | tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: repo not found"); | |
| @@ -128,7 +138,7 @@ async fn handle_new_issue( | |||
| 128 | 138 | // from any verified user. Returning OK (not 403) keeps the inbound path from | |
| 129 | 139 | // being an existence oracle for private repos (ultra-fuzz Run 4 M-Sec3). | |
| 130 | 140 | if repo.visibility == db::Visibility::Private && sender.id != owner_user.id { | |
| 131 | - | match db::repo_collaborators::is_collaborator(&state.db, repo.id, sender.id).await { | |
| 141 | + | match db::repo_collaborators::is_collaborator(db, repo.id, sender.id).await { | |
| 132 | 142 | Ok(true) => {} | |
| 133 | 143 | Ok(false) => { | |
| 134 | 144 | tracing::info!(owner = %owner, repo = %repo_name, "inbound-issues: sender lacks access to private repo"); | |
| @@ -145,7 +155,7 @@ async fn handle_new_issue( | |||
| 145 | 155 | // MessageID already maps to an issue, treat it as already-processed (audit | |
| 146 | 156 | // Run 13 Payments idempotency). | |
| 147 | 157 | if !payload.message_id.is_empty() { | |
| 148 | - | match db::issues::get_issue_id_by_any_message_id(&state.db, &[&payload.message_id]).await { | |
| 158 | + | match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await { | |
| 149 | 159 | Ok(Some(_)) => { | |
| 150 | 160 | tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate delivery; issue already created"); | |
| 151 | 161 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| @@ -172,7 +182,7 @@ async fn handle_new_issue( | |||
| 172 | 182 | }; | |
| 173 | 183 | ||
| 174 | 184 | let issue = match db::issues::create_issue( | |
| 175 | - | &state.db, repo.id, sender.id, title, body_md, &body_html, | |
| 185 | + | db, repo.id, sender.id, title, body_md, &body_html, | |
| 176 | 186 | ).await { | |
| 177 | 187 | Ok(i) => i, | |
| 178 | 188 | Err(e) => { | |
| @@ -183,7 +193,7 @@ async fn handle_new_issue( | |||
| 183 | 193 | ||
| 184 | 194 | // Store message ID mapping for threading | |
| 185 | 195 | if let Err(e) = db::issues::insert_issue_message_id( | |
| 186 | - | &state.db, &payload.message_id, issue.id, | |
| 196 | + | db, &payload.message_id, issue.id, | |
| 187 | 197 | ).await { | |
| 188 | 198 | tracing::error!(error = ?e, "inbound-issues: failed to store message-id mapping"); | |
| 189 | 199 | } | |
| @@ -192,7 +202,7 @@ async fn handle_new_issue( | |||
| 192 | 202 | // category so discussion happens on the forum rather than in long email | |
| 193 | 203 | // chains. Best-effort — if MT is unreachable or the repo has no project, | |
| 194 | 204 | // the issue itself is still created. | |
| 195 | - | bridge_new_issue_to_mt(state, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await; | |
| 205 | + | bridge_new_issue_to_mt(db, integrations, config, &repo, &issue, sender.id, &sender.username, sender.display_name.as_deref()).await; | |
| 196 | 206 | ||
| 197 | 207 | tracing::info!( | |
| 198 | 208 | issue_number = issue.number, | |
| @@ -202,9 +212,9 @@ async fn handle_new_issue( | |||
| 202 | 212 | ||
| 203 | 213 | // Notify repo owner (if different from sender) | |
| 204 | 214 | if sender.id != owner_user.id && owner_user.notify_issues { | |
| 205 | - | let email_client = state.email.clone(); | |
| 206 | - | let host_url = state.config.host_url.clone(); | |
| 207 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 215 | + | let email_client = email.clone(); | |
| 216 | + | let host_url = config.host_url.clone(); | |
| 217 | + | let signing_secret = config.signing_secret.clone(); | |
| 208 | 218 | let to_email = owner_user.email.clone(); | |
| 209 | 219 | let to_name = owner_user.display_name.clone(); | |
| 210 | 220 | let owner_id = owner_user.id; | |
| @@ -215,7 +225,7 @@ async fn handle_new_issue( | |||
| 215 | 225 | let issue_number = issue.number; | |
| 216 | 226 | let issue_id = issue.id; | |
| 217 | 227 | ||
| 218 | - | state.bg.spawn("issue notification email", async move { | |
| 228 | + | bg.spawn("issue notification email", async move { | |
| 219 | 229 | let issue_url = format!("{}/git/{}/{}/issues/{}", host_url, owner_name, repo_name, issue_number); | |
| 220 | 230 | let unsub_url = crate::email::generate_unsubscribe_url( | |
| 221 | 231 | &host_url, owner_id, crate::email::UnsubscribeAction::Issue, &owner_id.to_string(), &signing_secret, | |
| @@ -248,14 +258,19 @@ async fn handle_new_issue( | |||
| 248 | 258 | } | |
| 249 | 259 | ||
| 250 | 260 | /// Handle a reply to an existing issue via `issue+{id}.{uid}.{sig}@reply.makenot.work`. | |
| 261 | + | #[allow(clippy::too_many_arguments)] | |
| 251 | 262 | async fn handle_issue_reply( | |
| 252 | - | state: &AppState, | |
| 263 | + | db: &PgPool, | |
| 264 | + | bg: &BackgroundTx, | |
| 265 | + | email: &EmailClient, | |
| 266 | + | integrations: &Integrations, | |
| 267 | + | config: &Config, | |
| 253 | 268 | payload: &PostmarkInboundPayload, | |
| 254 | 269 | local_part: &str, | |
| 255 | 270 | ) -> HandlerOutcome { | |
| 256 | 271 | // Parse and verify the reply token | |
| 257 | 272 | let (issue_id, expected_user_id) = match crate::email::parse_issue_reply_token( | |
| 258 | - | local_part, &state.config.signing_secret, | |
| 273 | + | local_part, &config.signing_secret, | |
| 259 | 274 | ) { | |
| 260 | 275 | Some(ids) => ids, | |
| 261 | 276 | None => { | |
| @@ -276,13 +291,13 @@ async fn handle_issue_reply( | |||
| 276 | 291 | // as `sender` — so the `From` must itself be SPF/DKIM-authenticated, not just | |
| 277 | 292 | // match the token, before we trust it (Run 13 sender-spoofing). | |
| 278 | 293 | if !super::inbound_sender_trusted( | |
| 279 | - | state.config.email_webhooks.enforce_sender_auth, | |
| 294 | + | config.email_webhooks.enforce_sender_auth, | |
| 280 | 295 | &payload.from_full.email, | |
| 281 | 296 | &payload.headers, | |
| 282 | 297 | ) { | |
| 283 | 298 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 284 | 299 | } | |
| 285 | - | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 300 | + | let sender = match db::users::get_user_by_email(db, &sender_email).await { | |
| 286 | 301 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 287 | 302 | Ok(Some(_)) => { | |
| 288 | 303 | tracing::info!(email = %sender_email, "inbound-issues: reply sender not verified/suspended"); | |
| @@ -310,7 +325,7 @@ async fn handle_issue_reply( | |||
| 310 | 325 | // not create a DUPLICATE comment. If this MessageID is already mapped, skip | |
| 311 | 326 | // (audit Run 13 Payments idempotency). | |
| 312 | 327 | if !payload.message_id.is_empty() { | |
| 313 | - | match db::issues::get_issue_id_by_any_message_id(&state.db, &[&payload.message_id]).await { | |
| 328 | + | match db::issues::get_issue_id_by_any_message_id(db, &[&payload.message_id]).await { | |
| 314 | 329 | Ok(Some(_)) => { | |
| 315 | 330 | tracing::info!(message_id = %payload.message_id, "inbound-issues: duplicate reply delivery; comment already recorded"); | |
| 316 | 331 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| @@ -323,7 +338,7 @@ async fn handle_issue_reply( | |||
| 323 | 338 | } | |
| 324 | 339 | ||
| 325 | 340 | // Look up the issue | |
| 326 | - | let issue = match db::issues::get_issue_by_id(&state.db, issue_id).await { | |
| 341 | + | let issue = match db::issues::get_issue_by_id(db, issue_id).await { | |
| 327 | 342 | Ok(Some(i)) => i, | |
| 328 | 343 | Ok(None) => { | |
| 329 | 344 | tracing::info!(issue_id = %issue_id, "inbound-issues: issue not found for reply"); | |
| @@ -346,7 +361,7 @@ async fn handle_issue_reply( | |||
| 346 | 361 | ||
| 347 | 362 | // Create the comment | |
| 348 | 363 | if let Err(e) = db::issues::create_comment( | |
| 349 | - | &state.db, issue.id, sender.id, body_md, &body_html, | |
| 364 | + | db, issue.id, sender.id, body_md, &body_html, | |
| 350 | 365 | ).await { | |
| 351 | 366 | // Persisting the comment failed — transient; redeliver. | |
| 352 | 367 | return HandlerOutcome::Transient(anyhow::Error::new(e).context("inbound-issues: create comment")); | |
| @@ -354,13 +369,13 @@ async fn handle_issue_reply( | |||
| 354 | 369 | ||
| 355 | 370 | // Store message ID mapping for threading | |
| 356 | 371 | if let Err(e) = db::issues::insert_issue_message_id( | |
| 357 | - | &state.db, &payload.message_id, issue.id, | |
| 372 | + | db, &payload.message_id, issue.id, | |
| 358 | 373 | ).await { | |
| 359 | 374 | tracing::error!(error = ?e, "inbound-issues: failed to store reply message-id"); | |
| 360 | 375 | } | |
| 361 | 376 | ||
| 362 | 377 | // Bridge reply into the issue's MT thread (if one exists). | |
| 363 | - | bridge_issue_reply_to_mt(state, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md, &payload.message_id).await; | |
| 378 | + | bridge_issue_reply_to_mt(db, integrations, &issue, sender.id, &sender.username, sender.display_name.as_deref(), body_md, &payload.message_id).await; | |
| 364 | 379 | ||
| 365 | 380 | tracing::info!( | |
| 366 | 381 | issue_id = %issue.id, | |
| @@ -369,10 +384,10 @@ async fn handle_issue_reply( | |||
| 369 | 384 | ); | |
| 370 | 385 | ||
| 371 | 386 | // Notify all participants (minus the commenter) | |
| 372 | - | let db = state.db.clone(); | |
| 373 | - | let email_client = state.email.clone(); | |
| 374 | - | let host_url = state.config.host_url.clone(); | |
| 375 | - | let signing_secret = state.config.signing_secret.clone(); | |
| 387 | + | let db = db.clone(); | |
| 388 | + | let email_client = email.clone(); | |
| 389 | + | let host_url = config.host_url.clone(); | |
| 390 | + | let signing_secret = config.signing_secret.clone(); | |
| 376 | 391 | let commenter_id = sender.id; | |
| 377 | 392 | let commenter_username = sender.username.to_string(); | |
| 378 | 393 | let preview: String = body_md.chars().take(200).collect(); | |
| @@ -381,7 +396,7 @@ async fn handle_issue_reply( | |||
| 381 | 396 | let issue_id = issue.id; | |
| 382 | 397 | let repo_id = issue.repo_id; | |
| 383 | 398 | ||
| 384 | - | state.bg.spawn("issue reply notification email", async move { | |
| 399 | + | bg.spawn("issue reply notification email", async move { | |
| 385 | 400 | // Look up repo to get owner name | |
| 386 | 401 | let repo = match db::git_repos::get_repo_by_id(&db, repo_id).await { | |
| 387 | 402 | Ok(Some(r)) => r, | |
| @@ -458,15 +473,18 @@ async fn handle_issue_reply( | |||
| 458 | 473 | // emails into that thread as posts. See `docs/architecture.md` for the | |
| 459 | 474 | // philosophy. | |
| 460 | 475 | ||
| 476 | + | #[allow(clippy::too_many_arguments)] | |
| 461 | 477 | async fn bridge_new_issue_to_mt( | |
| 462 | - | state: &AppState, | |
| 478 | + | db: &PgPool, | |
| 479 | + | integrations: &Integrations, | |
| 480 | + | config: &Config, | |
| 463 | 481 | repo: &DbGitRepo, | |
| 464 | 482 | issue: &DbIssue, | |
| 465 | 483 | sender_id: crate::db::UserId, | |
| 466 | 484 | sender_username: &str, | |
| 467 | 485 | sender_display_name: Option<&str>, | |
| 468 | 486 | ) { | |
| 469 | - | let mt = match &state.mt_client { | |
| 487 | + | let mt = match &integrations.mt_client { | |
| 470 | 488 | Some(c) => c, | |
| 471 | 489 | None => return, | |
| 472 | 490 | }; | |
| @@ -476,7 +494,7 @@ async fn bridge_new_issue_to_mt( | |||
| 476 | 494 | return; | |
| 477 | 495 | }; | |
| 478 | 496 | ||
| 479 | - | let project = match db::projects::get_project_by_id(&state.db, project_id).await { | |
| 497 | + | let project = match db::projects::get_project_by_id(db, project_id).await { | |
| 480 | 498 | Ok(Some(p)) => p, | |
| 481 | 499 | _ => { | |
| 482 | 500 | tracing::warn!(project_id = %project_id, "inbound-issues: project lookup failed for MT bridge"); | |
| @@ -488,7 +506,7 @@ async fn bridge_new_issue_to_mt( | |||
| 488 | 506 | let body_markdown = format!( | |
| 489 | 507 | "**Issue [#{n}]({host}/git/{repo_owner}/{repo}/issues/{n})** opened by **{user}**.\n\n{body}", | |
| 490 | 508 | n = issue.number, | |
| 491 | - | host = state.config.host_url, | |
| 509 | + | host = config.host_url, | |
| 492 | 510 | repo_owner = sender_username, // placeholder — refined below | |
| 493 | 511 | repo = repo.name, | |
| 494 | 512 | user = sender_display_name.unwrap_or(sender_username), | |
| @@ -497,7 +515,7 @@ async fn bridge_new_issue_to_mt( | |||
| 497 | 515 | ||
| 498 | 516 | // The git issue URL needs the *repo owner's* username, which we can derive | |
| 499 | 517 | // from the repo row's user_id — fetch it (cheap, one row). | |
| 500 | - | let repo_owner_username = match db::users::get_user_by_id(&state.db, repo.user_id).await { | |
| 518 | + | let repo_owner_username = match db::users::get_user_by_id(db, repo.user_id).await { | |
| 501 | 519 | Ok(Some(u)) => u.username.to_string(), | |
| 502 | 520 | _ => sender_username.to_string(), | |
| 503 | 521 | }; | |
| @@ -519,7 +537,7 @@ async fn bridge_new_issue_to_mt( | |||
| 519 | 537 | ||
| 520 | 538 | match mt.create_thread(&req).await { | |
| 521 | 539 | Ok(resp) => { | |
| 522 | - | if let Err(e) = db::issues::set_mt_thread_id(&state.db, issue.id, *resp.thread_id).await { | |
| 540 | + | if let Err(e) = db::issues::set_mt_thread_id(db, issue.id, *resp.thread_id).await { | |
| 523 | 541 | tracing::warn!(error = ?e, "inbound-issues: failed to store mt_thread_id"); | |
| 524 | 542 | } | |
| 525 | 543 | } | |
| @@ -529,8 +547,10 @@ async fn bridge_new_issue_to_mt( | |||
| 529 | 547 | } | |
| 530 | 548 | } | |
| 531 | 549 | ||
| 550 | + | #[allow(clippy::too_many_arguments)] | |
| 532 | 551 | async fn bridge_issue_reply_to_mt( | |
| 533 | - | state: &AppState, | |
| 552 | + | db: &PgPool, | |
| 553 | + | integrations: &Integrations, | |
| 534 | 554 | issue: &DbIssue, | |
| 535 | 555 | sender_id: crate::db::UserId, | |
| 536 | 556 | sender_username: &str, | |
| @@ -538,7 +558,7 @@ async fn bridge_issue_reply_to_mt( | |||
| 538 | 558 | body_markdown: &str, | |
| 539 | 559 | message_id: &str, | |
| 540 | 560 | ) { | |
| 541 | - | let mt = match &state.mt_client { | |
| 561 | + | let mt = match &integrations.mt_client { | |
| 542 | 562 | Some(c) => c, | |
| 543 | 563 | None => return, | |
| 544 | 564 | }; | |
| @@ -547,7 +567,7 @@ async fn bridge_issue_reply_to_mt( | |||
| 547 | 567 | // idempotent create_thread call (no-op if the thread already exists). | |
| 548 | 568 | let thread_id = match issue.mt_thread_id { | |
| 549 | 569 | Some(id) => id, | |
| 550 | - | None => match resolve_or_create_issue_thread(state, mt, issue, sender_id, sender_username, sender_display_name).await { | |
| 570 | + | None => match resolve_or_create_issue_thread(db, mt, issue, sender_id, sender_username, sender_display_name).await { | |
| 551 | 571 | Some(id) => id, | |
| 552 | 572 | None => return, | |
| 553 | 573 | }, | |
| @@ -569,16 +589,16 @@ async fn bridge_issue_reply_to_mt( | |||
| 569 | 589 | /// failed: re-issues `create_thread` (idempotent via `external_ref`) to obtain | |
| 570 | 590 | /// the canonical thread ID, then caches it. | |
| 571 | 591 | async fn resolve_or_create_issue_thread( | |
| 572 | - | state: &AppState, | |
| 592 | + | db: &PgPool, | |
| 573 | 593 | mt: &mt_client::MtClient, | |
| 574 | 594 | issue: &DbIssue, | |
| 575 | 595 | sender_id: crate::db::UserId, | |
| 576 | 596 | sender_username: &str, | |
| 577 | 597 | sender_display_name: Option<&str>, | |
| 578 | 598 | ) -> Option<uuid::Uuid> { | |
| 579 | - | let repo = db::git_repos::get_repo_by_id(&state.db, issue.repo_id).await.ok().flatten()?; | |
| 599 | + | let repo = db::git_repos::get_repo_by_id(db, issue.repo_id).await.ok().flatten()?; | |
| 580 | 600 | let project_id = repo.project_id?; | |
| 581 | - | let project = db::projects::get_project_by_id(&state.db, project_id).await.ok().flatten()?; | |
| 601 | + | let project = db::projects::get_project_by_id(db, project_id).await.ok().flatten()?; | |
| 582 | 602 | ||
| 583 | 603 | let req = mt_client::CreateThreadRequest { | |
| 584 | 604 | community_slug: project.slug.to_string(), | |
| @@ -592,7 +612,7 @@ async fn resolve_or_create_issue_thread( | |||
| 592 | 612 | }; | |
| 593 | 613 | let resp = mt.create_thread(&req).await.ok()?; | |
| 594 | 614 | let thread_id: uuid::Uuid = *resp.thread_id; | |
| 595 | - | let _ = db::issues::set_mt_thread_id(&state.db, issue.id, thread_id).await; | |
| 615 | + | let _ = db::issues::set_mt_thread_id(db, issue.id, thread_id).await; | |
| 596 | 616 | Some(thread_id) | |
| 597 | 617 | } | |
| 598 | 618 |
| @@ -15,9 +15,11 @@ use axum::{ | |||
| 15 | 15 | use serde::Deserialize; | |
| 16 | 16 | ||
| 17 | 17 | use crate::{ | |
| 18 | + | config::Config, | |
| 18 | 19 | csrf::{post_csrf_skip, CsrfRouter}, | |
| 19 | 20 | db, AppState, | |
| 20 | 21 | }; | |
| 22 | + | use sqlx::PgPool; | |
| 21 | 23 | ||
| 22 | 24 | /// Subset of a Postmark webhook payload we care about. | |
| 23 | 25 | #[derive(Debug, Deserialize)] | |
| @@ -105,19 +107,20 @@ pub(super) fn verify_token(headers: &HeaderMap, expected: &str) -> bool { | |||
| 105 | 107 | /// - Everything else -> log and return 200 | |
| 106 | 108 | #[tracing::instrument(skip_all, name = "postmark::postmark_webhook")] | |
| 107 | 109 | async fn postmark_webhook( | |
| 108 | - | State(state): State<AppState>, | |
| 110 | + | State(db): State<PgPool>, | |
| 111 | + | State(config): State<Config>, | |
| 109 | 112 | headers: HeaderMap, | |
| 110 | 113 | Json(payload): Json<PostmarkWebhookPayload>, | |
| 111 | 114 | ) -> HandlerOutcome { | |
| 112 | 115 | // Authenticate -- accept either transactional or broadcast webhook token | |
| 113 | - | let transactional_ok = state.config.email_webhooks.webhook_token.as_deref() | |
| 116 | + | let transactional_ok = config.email_webhooks.webhook_token.as_deref() | |
| 114 | 117 | .is_some_and(|t| verify_token(&headers, t)); | |
| 115 | - | let broadcast_ok = state.config.email_webhooks.broadcast_webhook_token.as_deref() | |
| 118 | + | let broadcast_ok = config.email_webhooks.broadcast_webhook_token.as_deref() | |
| 116 | 119 | .is_some_and(|t| verify_token(&headers, t)); | |
| 117 | 120 | ||
| 118 | 121 | if !transactional_ok && !broadcast_ok { | |
| 119 | - | if state.config.email_webhooks.webhook_token.is_none() | |
| 120 | - | && state.config.email_webhooks.broadcast_webhook_token.is_none() | |
| 122 | + | if config.email_webhooks.webhook_token.is_none() | |
| 123 | + | && config.email_webhooks.broadcast_webhook_token.is_none() | |
| 121 | 124 | { | |
| 122 | 125 | tracing::warn!("Postmark webhook received but no webhook tokens configured"); | |
| 123 | 126 | } else { | |
| @@ -135,7 +138,7 @@ async fn postmark_webhook( | |||
| 135 | 138 | // redelivers, rather than leaving a hard-bounced address un-suppressed | |
| 136 | 139 | // (deliverability/compliance drift). | |
| 137 | 140 | if let Err(e) = db::email_suppressions::add_suppression( | |
| 138 | - | &state.db, | |
| 141 | + | &db, | |
| 139 | 142 | &payload.email, | |
| 140 | 143 | "HardBounce", | |
| 141 | 144 | ).await { | |
| @@ -154,7 +157,7 @@ async fn postmark_webhook( | |||
| 154 | 157 | "SpamComplaint" => { | |
| 155 | 158 | tracing::info!(email = %payload.email, "Postmark spam complaint — adding to suppression list"); | |
| 156 | 159 | if let Err(e) = db::email_suppressions::add_suppression( | |
| 157 | - | &state.db, | |
| 160 | + | &db, | |
| 158 | 161 | &payload.email, | |
| 159 | 162 | "SpamComplaint", | |
| 160 | 163 | ).await { |
| @@ -6,7 +6,8 @@ use axum::{ | |||
| 6 | 6 | Json, | |
| 7 | 7 | }; | |
| 8 | 8 | ||
| 9 | - | use crate::{db, mt_client, AppState}; | |
| 9 | + | use crate::{config::Config, db, mt_client, Integrations}; | |
| 10 | + | use sqlx::PgPool; | |
| 10 | 11 | ||
| 11 | 12 | use super::{verify_token, HandlerOutcome, PostmarkInboundPayload}; | |
| 12 | 13 | ||
| @@ -20,16 +21,18 @@ use super::{verify_token, HandlerOutcome, PostmarkInboundPayload}; | |||
| 20 | 21 | /// 5. Maps email Message-IDs for multi-part patch series threading | |
| 21 | 22 | #[tracing::instrument(skip_all, name = "postmark::inbound")] | |
| 22 | 23 | pub(super) async fn postmark_inbound( | |
| 23 | - | State(state): State<AppState>, | |
| 24 | + | State(db): State<PgPool>, | |
| 25 | + | State(config): State<Config>, | |
| 26 | + | State(integrations): State<Integrations>, | |
| 24 | 27 | headers: HeaderMap, | |
| 25 | 28 | Json(payload): Json<PostmarkInboundPayload>, | |
| 26 | 29 | ) -> HandlerOutcome { | |
| 27 | 30 | // 1. Auth — verify bearer token | |
| 28 | - | let token_ok = state.config.email_webhooks.inbound_webhook_token.as_deref() | |
| 31 | + | let token_ok = config.email_webhooks.inbound_webhook_token.as_deref() | |
| 29 | 32 | .is_some_and(|t| verify_token(&headers, t)); | |
| 30 | 33 | ||
| 31 | 34 | if !token_ok { | |
| 32 | - | if state.config.email_webhooks.inbound_webhook_token.is_none() { | |
| 35 | + | if config.email_webhooks.inbound_webhook_token.is_none() { | |
| 33 | 36 | tracing::warn!("Postmark inbound received but no token configured"); | |
| 34 | 37 | } else { | |
| 35 | 38 | tracing::warn!("Postmark inbound: invalid bearer token"); | |
| @@ -47,7 +50,7 @@ pub(super) async fn postmark_inbound( | |||
| 47 | 50 | }; | |
| 48 | 51 | ||
| 49 | 52 | // 3. Look up project | |
| 50 | - | let project = match db::projects::get_public_project_by_slug_str(&state.db, &project_slug).await { | |
| 53 | + | let project = match db::projects::get_public_project_by_slug_str(&db, &project_slug).await { | |
| 51 | 54 | Ok(Some(p)) => p, | |
| 52 | 55 | Ok(None) => { | |
| 53 | 56 | tracing::info!(slug = %project_slug, "inbound: project not found"); | |
| @@ -71,13 +74,13 @@ pub(super) async fn postmark_inbound( | |||
| 71 | 74 | // aligns with its domain (Run 13 sender-spoofing). Terminal OK: retrying | |
| 72 | 75 | // can't authenticate a spoof, and a non-committal 200 is no existence oracle. | |
| 73 | 76 | if !super::inbound_sender_trusted( | |
| 74 | - | state.config.email_webhooks.enforce_sender_auth, | |
| 77 | + | config.email_webhooks.enforce_sender_auth, | |
| 75 | 78 | &payload.from_full.email, | |
| 76 | 79 | &payload.headers, | |
| 77 | 80 | ) { | |
| 78 | 81 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| 79 | 82 | } | |
| 80 | - | let sender = match db::users::get_user_by_email(&state.db, &sender_email).await { | |
| 83 | + | let sender = match db::users::get_user_by_email(&db, &sender_email).await { | |
| 81 | 84 | Ok(Some(u)) if u.email_verified && !u.is_suspended() => u, | |
| 82 | 85 | Ok(Some(u)) if u.is_suspended() => { | |
| 83 | 86 | tracing::info!(email = %sender_email, "inbound: sender is suspended"); | |
| @@ -98,7 +101,7 @@ pub(super) async fn postmark_inbound( | |||
| 98 | 101 | }; | |
| 99 | 102 | ||
| 100 | 103 | // 5. Check MT client is available | |
| 101 | - | let mt = match &state.mt_client { | |
| 104 | + | let mt = match &integrations.mt_client { | |
| 102 | 105 | Some(c) => c, | |
| 103 | 106 | None => { | |
| 104 | 107 | tracing::warn!("inbound: MT client not configured, cannot process patch"); | |
| @@ -113,7 +116,7 @@ pub(super) async fn postmark_inbound( | |||
| 113 | 116 | // duplicate thread or post regardless. This local check just skips the | |
| 114 | 117 | // redundant MT round-trip when this exact patch was already threaded. | |
| 115 | 118 | if !payload.message_id.is_empty() { | |
| 116 | - | match db::patches::get_thread_id_by_any_message_id(&state.db, &[payload.message_id.as_str()]).await { | |
| 119 | + | match db::patches::get_thread_id_by_any_message_id(&db, &[payload.message_id.as_str()]).await { | |
| 117 | 120 | Ok(Some(_)) => { | |
| 118 | 121 | tracing::info!(message_id = %payload.message_id, "inbound: duplicate patch delivery; already threaded"); | |
| 119 | 122 | return HandlerOutcome::Terminal(StatusCode::OK); | |
| @@ -145,7 +148,7 @@ pub(super) async fn postmark_inbound( | |||
| 145 | 148 | let existing_thread = if ref_ids.is_empty() { | |
| 146 | 149 | None | |
| 147 | 150 | } else { | |
| 148 | - | match db::patches::get_thread_id_by_any_message_id(&state.db, &ref_ids).await { | |
| 151 | + | match db::patches::get_thread_id_by_any_message_id(&db, &ref_ids).await { | |
| 149 | 152 | Ok(t) => t, | |
| 150 | 153 | Err(e) => { | |
| 151 | 154 | // DB error — transient; redeliver. | |
| @@ -224,7 +227,7 @@ pub(super) async fn postmark_inbound( | |||
| 224 | 227 | // resolves to the same thread/post, never a duplicate. The mapping is just a | |
| 225 | 228 | // threading hint for later series parts. Log and ack — the patch was delivered. | |
| 226 | 229 | if let Err(e) = db::patches::insert_patch_message_id( | |
| 227 | - | &state.db, | |
| 230 | + | &db, | |
| 228 | 231 | &payload.message_id, | |
| 229 | 232 | project.id, | |
| 230 | 233 | thread_id, |
| @@ -31,10 +31,11 @@ use axum::{ | |||
| 31 | 31 | }; | |
| 32 | 32 | ||
| 33 | 33 | use crate::{ | |
| 34 | + | config::Config, | |
| 34 | 35 | custom_pages, | |
| 35 | 36 | db::{self, PricingKind, Slug, Username}, | |
| 36 | - | AppState, | |
| 37 | 37 | }; | |
| 38 | + | use sqlx::PgPool; | |
| 38 | 39 | ||
| 39 | 40 | /// One entry in a project's file-list system slot. | |
| 40 | 41 | struct SlotItem { | |
| @@ -87,9 +88,14 @@ struct ItemPageTemplate { | |||
| 87 | 88 | /// Dispatch middleware: intercept the user-pages host, pass everything else | |
| 88 | 89 | /// (and `/static`) through to the normal app. Placed outermost so it runs | |
| 89 | 90 | /// before the session and access-gate layers -- custom pages never touch them. | |
| 90 | - | pub async fn dispatch(State(state): State<AppState>, req: Request<Body>, next: Next) -> Response { | |
| 91 | + | pub async fn dispatch( | |
| 92 | + | State(db): State<PgPool>, | |
| 93 | + | State(config): State<Config>, | |
| 94 | + | req: Request<Body>, | |
| 95 | + | next: Next, | |
| 96 | + | ) -> Response { | |
| 91 | 97 | let host = extract_host(req.headers()); | |
| 92 | - | if host.as_deref() != Some(&*state.config.user_pages_host) { | |
| 98 | + | if host.as_deref() != Some(&*config.user_pages_host) { | |
| 93 | 99 | return next.run(req).await; | |
| 94 | 100 | } | |
| 95 | 101 | ||
| @@ -99,11 +105,11 @@ pub async fn dispatch(State(state): State<AppState>, req: Request<Body>, next: N | |||
| 99 | 105 | return next.run(req).await; | |
| 100 | 106 | } | |
| 101 | 107 | ||
| 102 | - | serve(&state, &path).await | |
| 108 | + | serve(&db, &config, &path).await | |
| 103 | 109 | } | |
| 104 | 110 | ||
| 105 | 111 | /// Render a custom page for `path` and stamp the strict CSP + security headers. | |
| 106 | - | async fn serve(state: &AppState, path: &str) -> Response { | |
| 112 | + | async fn serve(db: &PgPool, config: &Config, path: &str) -> Response { | |
| 107 | 113 | let segments: Vec<&str> = path.split('/').filter(|s| !s.is_empty()).collect(); | |
| 108 | 114 | ||
| 109 | 115 | // Editor preview: an unguessable draft id renders the in-progress page. | |
| @@ -111,11 +117,11 @@ async fn serve(state: &AppState, path: &str) -> Response { | |||
| 111 | 117 | ||
| 112 | 118 | let result = match segments.as_slice() { | |
| 113 | 119 | // Bare host with no handle: send visitors to the apex. | |
| 114 | - | [] => return redirect_to_apex(state), | |
| 115 | - | [first, draft_id] if *first == "preview" => render_preview(state, draft_id).await, | |
| 116 | - | [handle] => render_user(state, handle).await, | |
| 117 | - | [handle, project] => render_project(state, handle, project).await, | |
| 118 | - | [handle, project, item] => render_item(state, handle, project, item).await, | |
| 120 | + | [] => return redirect_to_apex(config), | |
| 121 | + | [first, draft_id] if *first == "preview" => render_preview(db, config, draft_id).await, | |
| 122 | + | [handle] => render_user(db, config, handle).await, | |
| 123 | + | [handle, project] => render_project(db, config, handle, project).await, | |
| 124 | + | [handle, project, item] => render_item(db, config, handle, project, item).await, | |
| 119 | 125 | _ => Err(StatusCode::NOT_FOUND), | |
| 120 | 126 | }; | |
| 121 | 127 | ||
| @@ -123,7 +129,7 @@ async fn serve(state: &AppState, path: &str) -> Response { | |||
| 123 | 129 | Ok(resp) => resp, | |
| 124 | 130 | Err(code) => (code, "Not found").into_response(), | |
| 125 | 131 | }; | |
| 126 | - | apply_security_headers(response.headers_mut(), state, is_preview); | |
| 132 | + | apply_security_headers(response.headers_mut(), config, is_preview); | |
| 127 | 133 | if is_preview { | |
| 128 | 134 | // Previews are per-keystroke; never cache them. | |
| 129 | 135 | response | |
| @@ -133,21 +139,21 @@ async fn serve(state: &AppState, path: &str) -> Response { | |||
| 133 | 139 | response | |
| 134 | 140 | } | |
| 135 | 141 | ||
| 136 | - | fn redirect_to_apex(state: &AppState) -> Response { | |
| 142 | + | fn redirect_to_apex(config: &Config) -> Response { | |
| 137 | 143 | let mut response = | |
| 138 | - | axum::response::Redirect::temporary(&state.config.host_url).into_response(); | |
| 139 | - | apply_security_headers(response.headers_mut(), state, false); | |
| 144 | + | axum::response::Redirect::temporary(&config.host_url).into_response(); | |
| 145 | + | apply_security_headers(response.headers_mut(), config, false); | |
| 140 | 146 | response | |
| 141 | 147 | } | |
| 142 | 148 | ||
| 143 | - | async fn render_user(state: &AppState, handle: &str) -> Result<Response, StatusCode> { | |
| 149 | + | async fn render_user(db: &PgPool, config: &Config, handle: &str) -> Result<Response, StatusCode> { | |
| 144 | 150 | let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?; | |
| 145 | - | let user = db::users::get_user_by_username(&state.db, &username) | |
| 151 | + | let user = db::users::get_user_by_username(db, &username) | |
| 146 | 152 | .await | |
| 147 | 153 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 148 | 154 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 149 | 155 | ||
| 150 | - | let apex_url = state.config.host_url.to_string(); | |
| 156 | + | let apex_url = config.host_url.to_string(); | |
| 151 | 157 | let canonical_url = format!("{apex_url}/u/{}", user.username); | |
| 152 | 158 | let creator_label = display_name(&user); | |
| 153 | 159 | ||
| @@ -155,7 +161,7 @@ async fn render_user(state: &AppState, handle: &str) -> Result<Response, StatusC | |||
| 155 | 161 | let (sanitized_html, sanitized_css) = if user.custom_pages_locked { | |
| 156 | 162 | (String::new(), String::new()) | |
| 157 | 163 | } else { | |
| 158 | - | sanitize_user_page(state, &user) | |
| 164 | + | sanitize_user_page(config, &user) | |
| 159 | 165 | }; | |
| 160 | 166 | ||
| 161 | 167 | render_html(user_template(&user, apex_url, canonical_url, creator_label, sanitized_html, sanitized_css)) | |
| @@ -187,35 +193,35 @@ fn render_html(template: impl Template) -> Result<Response, StatusCode> { | |||
| 187 | 193 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR) | |
| 188 | 194 | } | |
| 189 | 195 | ||
| 190 | - | async fn render_project(state: &AppState, handle: &str, project_slug: &str) -> Result<Response, StatusCode> { | |
| 196 | + | async fn render_project(db: &PgPool, config: &Config, handle: &str, project_slug: &str) -> Result<Response, StatusCode> { | |
| 191 | 197 | let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?; | |
| 192 | - | let user = db::users::get_user_by_username(&state.db, &username) | |
| 198 | + | let user = db::users::get_user_by_username(db, &username) | |
| 193 | 199 | .await | |
| 194 | 200 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 195 | 201 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 196 | 202 | let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?; | |
| 197 | - | let project = db::projects::get_public_project_by_user_and_slug(&state.db, user.id, &slug) | |
| 203 | + | let project = db::projects::get_public_project_by_user_and_slug(db, user.id, &slug) | |
| 198 | 204 | .await | |
| 199 | 205 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 200 | 206 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 201 | 207 | ||
| 202 | - | let apex_url = state.config.host_url.to_string(); | |
| 208 | + | let apex_url = config.host_url.to_string(); | |
| 203 | 209 | let canonical_url = format!("{apex_url}/p/{}", project.slug); | |
| 204 | 210 | ||
| 205 | 211 | // A locked owner falls back to the platform default everywhere. | |
| 206 | 212 | let (sanitized_html, sanitized_css) = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() { | |
| 207 | 213 | (String::new(), String::new()) | |
| 208 | 214 | } else { | |
| 209 | - | sanitize_project_page(state, &project) | |
| 215 | + | sanitize_project_page(config, &project) | |
| 210 | 216 | }; | |
| 211 | 217 | ||
| 212 | - | let items = project_slot_items(state, &project, &apex_url).await; | |
| 218 | + | let items = project_slot_items(db, &project, &apex_url).await; | |
| 213 | 219 | render_html(project_template(&user, &project, apex_url, canonical_url, sanitized_html, sanitized_css, items)) | |
| 214 | 220 | } | |
| 215 | 221 | ||
| 216 | 222 | /// The project's published items as file-list slot entries (links to the apex). | |
| 217 | - | async fn project_slot_items(state: &AppState, project: &db::DbProject, apex_url: &str) -> Vec<SlotItem> { | |
| 218 | - | db::items::get_public_items_by_project(&state.db, project.id) | |
| 223 | + | async fn project_slot_items(db: &PgPool, project: &db::DbProject, apex_url: &str) -> Vec<SlotItem> { | |
| 224 | + | db::items::get_public_items_by_project(db, project.id) | |
| 219 | 225 | .await | |
| 220 | 226 | .unwrap_or_default() | |
| 221 | 227 | .into_iter() | |
| @@ -253,19 +259,19 @@ fn project_template( | |||
| 253 | 259 | /// Render an editor draft preview (capability URL keyed by draft id). Branches | |
| 254 | 260 | /// on the draft's page kind and renders the same templates the live page uses, | |
| 255 | 261 | /// with the draft's sanitized content. | |
| 256 | - | async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, StatusCode> { | |
| 262 | + | async fn render_preview(db: &PgPool, config: &Config, draft_id: &str) -> Result<Response, StatusCode> { | |
| 257 | 263 | let id = uuid::Uuid::parse_str(draft_id).map_err(|_| StatusCode::NOT_FOUND)?; | |
| 258 | - | let draft = db::custom_pages::get_draft(&state.db, id) | |
| 264 | + | let draft = db::custom_pages::get_draft(db, id) | |
| 259 | 265 | .await | |
| 260 | 266 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 261 | 267 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 262 | 268 | ||
| 263 | - | let apex_url = state.config.host_url.to_string(); | |
| 264 | - | let policy = state.config.custom_pages_policy(); | |
| 269 | + | let apex_url = config.host_url.to_string(); | |
| 270 | + | let policy = config.custom_pages_policy(); | |
| 265 | 271 | ||
| 266 | 272 | match draft.page_kind.as_str() { | |
| 267 | 273 | db::custom_pages::KIND_USER => { | |
| 268 | - | let user = db::users::get_user_by_id(&state.db, db::UserId::from_uuid(draft.page_id)) | |
| 274 | + | let user = db::users::get_user_by_id(db, db::UserId::from_uuid(draft.page_id)) | |
| 269 | 275 | .await | |
| 270 | 276 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 271 | 277 | .ok_or(StatusCode::NOT_FOUND)?; | |
| @@ -281,11 +287,11 @@ async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, St | |||
| 281 | 287 | render_html(user_template(&user, apex_url, canonical_url, label, html, css)) | |
| 282 | 288 | } | |
| 283 | 289 | db::custom_pages::KIND_PROJECT => { | |
| 284 | - | let project = db::projects::get_project_by_id(&state.db, db::ProjectId::from_uuid(draft.page_id)) | |
| 290 | + | let project = db::projects::get_project_by_id(db, db::ProjectId::from_uuid(draft.page_id)) | |
| 285 | 291 | .await | |
| 286 | 292 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 287 | 293 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 288 | - | let user = db::users::get_user_by_id(&state.db, project.user_id) | |
| 294 | + | let user = db::users::get_user_by_id(db, project.user_id) | |
| 289 | 295 | .await | |
| 290 | 296 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 291 | 297 | .ok_or(StatusCode::NOT_FOUND)?; | |
| @@ -297,7 +303,7 @@ async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, St | |||
| 297 | 303 | None => (String::new(), String::new()), | |
| 298 | 304 | }; | |
| 299 | 305 | let canonical_url = format!("{apex_url}/p/{}", project.slug); | |
| 300 | - | let items = project_slot_items(state, &project, &apex_url).await; | |
| 306 | + | let items = project_slot_items(db, &project, &apex_url).await; | |
| 301 | 307 | render_html(project_template(&user, &project, apex_url, canonical_url, html, css, items)) | |
| 302 | 308 | } | |
| 303 | 309 | _ => Err(StatusCode::NOT_FOUND), | |
| @@ -305,22 +311,23 @@ async fn render_preview(state: &AppState, draft_id: &str) -> Result<Response, St | |||
| 305 | 311 | } | |
| 306 | 312 | ||
| 307 | 313 | async fn render_item( | |
| 308 | - | state: &AppState, | |
| 314 | + | db: &PgPool, | |
| 315 | + | config: &Config, | |
| 309 | 316 | handle: &str, | |
| 310 | 317 | project_slug: &str, | |
| 311 | 318 | item_slug: &str, | |
| 312 | 319 | ) -> Result<Response, StatusCode> { | |
| 313 | 320 | let username = Username::new(handle).map_err(|_| StatusCode::NOT_FOUND)?; | |
| 314 | - | let user = db::users::get_user_by_username(&state.db, &username) | |
| 321 | + | let user = db::users::get_user_by_username(db, &username) | |
| 315 | 322 | .await | |
| 316 | 323 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 317 | 324 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 318 | 325 | let slug = Slug::new(project_slug).map_err(|_| StatusCode::NOT_FOUND)?; | |
| 319 | - | let project = db::projects::get_public_project_by_user_and_slug(&state.db, user.id, &slug) | |
| 326 | + | let project = db::projects::get_public_project_by_user_and_slug(db, user.id, &slug) | |
| 320 | 327 | .await | |
| 321 | 328 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 322 | 329 | .ok_or(StatusCode::NOT_FOUND)?; | |
| 323 | - | let item = db::items::get_item_by_project_and_slug(&state.db, project.id, item_slug) | |
| 330 | + | let item = db::items::get_item_by_project_and_slug(db, project.id, item_slug) | |
| 324 | 331 | .await | |
| 325 | 332 | .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)? | |
| 326 | 333 | .ok_or(StatusCode::NOT_FOUND)?; | |
| @@ -329,7 +336,7 @@ async fn render_item( | |||
| 329 | 336 | return Err(StatusCode::NOT_FOUND); | |
| 330 | 337 | } | |
| 331 | 338 | ||
| 332 | - | let apex_url = state.config.host_url.to_string(); | |
| 339 | + | let apex_url = config.host_url.to_string(); | |
| 333 | 340 | let canonical_url = format!("{apex_url}/i/{}", item.id); | |
| 334 | 341 | ||
| 335 | 342 | // Item pages inherit the parent project's CSS, re-scoped to the item canvas. | |
| @@ -337,7 +344,7 @@ async fn render_item( | |||
| 337 | 344 | let sanitized_css = if user.custom_pages_locked || project.custom_pages_updated_at.is_none() { | |
| 338 | 345 | String::new() | |
| 339 | 346 | } else { | |
| 340 | - | sanitize_item_css(state, &project) | |
| 347 | + | sanitize_item_css(config, &project) | |
| 341 | 348 | }; | |
| 342 | 349 | ||
| 343 | 350 | let price_label = item_price_label(&item); | |
| @@ -361,8 +368,8 @@ async fn render_item( | |||
| 361 | 368 | ||
| 362 | 369 | // ── Sanitization (on render) ──────────────────────────────────────────────── | |
| 363 | 370 | ||
| 364 | - | fn sanitize_user_page(state: &AppState, user: &db::DbUser) -> (String, String) { | |
| 365 | - | let Some(policy) = state.config.custom_pages_policy() else { | |
| 371 | + | fn sanitize_user_page(config: &Config, user: &db::DbUser) -> (String, String) { | |
| 372 | + | let Some(policy) = config.custom_pages_policy() else { | |
| 366 | 373 | return (String::new(), String::new()); | |
| 367 | 374 | }; | |
| 368 | 375 | let (html, css, _rej) = | |
| @@ -370,8 +377,8 @@ fn sanitize_user_page(state: &AppState, user: &db::DbUser) -> (String, String) { | |||
| 370 | 377 | (html, css) | |
| 371 | 378 | } | |
| 372 | 379 | ||
| 373 | - | fn sanitize_project_page(state: &AppState, project: &db::DbProject) -> (String, String) { | |
| 374 | - | let Some(policy) = state.config.custom_pages_policy() else { | |
| 380 | + | fn sanitize_project_page(config: &Config, project: &db::DbProject) -> (String, String) { | |
| 381 | + | let Some(policy) = config.custom_pages_policy() else { | |
| 375 | 382 | return (String::new(), String::new()); | |
| 376 | 383 | }; | |
| 377 | 384 | let (html, css, _rej) = custom_pages::sanitize_page( | |
| @@ -383,8 +390,8 @@ fn sanitize_project_page(state: &AppState, project: &db::DbProject) -> (String, | |||
| 383 | 390 | (html, css) | |
| 384 | 391 | } | |
| 385 | 392 | ||
| 386 | - | fn sanitize_item_css(state: &AppState, project: &db::DbProject) -> String { | |
| 387 | - | let Some(policy) = state.config.custom_pages_policy() else { | |
| 393 | + | fn sanitize_item_css(config: &Config, project: &db::DbProject) -> String { | |
| 394 | + | let Some(policy) = config.custom_pages_policy() else { | |
| 388 | 395 | return String::new(); | |
| 389 | 396 | }; | |
| 390 | 397 | let (css, _rej) = custom_pages::sanitize_item_css(&project.custom_css, &project.id.to_string(), &policy); | |
| @@ -442,15 +449,15 @@ fn extract_host(headers: &HeaderMap) -> Option<String> { | |||
| 442 | 449 | /// Public pages forbid framing entirely (`frame-ancestors 'none'`). A preview, | |
| 443 | 450 | /// though, must be embeddable in the apex editor iframe, so it allows exactly | |
| 444 | 451 | /// the apex origin to frame it -- nothing else. | |
| 445 | - | fn apply_security_headers(headers: &mut HeaderMap, state: &AppState, is_preview: bool) { | |
| 446 | - | let cdn = state.config.cdn_base_url.as_deref().unwrap_or(""); | |
| 452 | + | fn apply_security_headers(headers: &mut HeaderMap, config: &Config, is_preview: bool) { | |
| 453 | + | let cdn = config.cdn_base_url.as_deref().unwrap_or(""); | |
| 447 | 454 | let media_src = if cdn.is_empty() { | |
| 448 | 455 | "'self'".to_string() | |
| 449 | 456 | } else { | |
| 450 | 457 | format!("'self' {cdn}") | |
| 451 | 458 | }; | |
| 452 | 459 | let frame_ancestors = if is_preview { | |
| 453 | - | format!("'self' {}", state.config.host_url) | |
| 460 | + | format!("'self' {}", config.host_url) | |
| 454 | 461 | } else { | |
| 455 | 462 | "'none'".to_string() | |
| 456 | 463 | }; |