1use askama::Template;
10use axum::extract::{Path, State};
11use axum::http::{HeaderMap, HeaderName, HeaderValue, StatusCode, header};
12use axum::response::{AppendHeaders, IntoResponse, Redirect, Response};
13use axum::routing::{get, post};
14use axum::{Form, Router};
15use codoseo_checks::def;
16use codoseo_core::check::{CheckId, Severity};
17use codoseo_core::output::StopReason;
18use codoseo_core::plan::PlanLimits;
19use codoseo_store::accounts::Account;
20use codoseo_store::crawls::{Crawl, CrawlStatus};
21use codoseo_store::events::{self, EventKind};
22use codoseo_store::quick::{
23 self, Audit, ClaimOutcome, LimitWindow, Limits, Source, StartOutcome, StartRequest, UnlockSlot,
24};
25use serde::Deserialize;
26use serde_json::json;
27use uuid::Uuid;
28
29use super::sites::{FIRST_CRAWL_PRIORITY, check_public_target, parse_start_url, schedule_for};
30use crate::abuse::{self, ClientIp};
31use crate::auth::magic::{self, AuditLink, LinkOutcome};
32use crate::auth::{email, session};
33use crate::config::Mode;
34use crate::error::AppError;
35use crate::fmt;
36use crate::render::{Hx, html, hx_redirect};
37use crate::state::AppState;
38use crate::turnstile::{self, Verdict};
39
40pub const CLAIM_COOKIE: &str = "codoseo_audit";
41const CLAIM_TTL_SECS: i64 = 7 * 24 * 3600;
43const MAX_CLAIM_TOKENS: usize = 5;
46const PREVIEW_ISSUES: usize = 5;
48
49pub fn routes() -> Router<AppState> {
50 Router::new()
51 .route("/audit", post(start))
52 .route("/audit/{id}", get(report))
53 .route("/audit/{id}/live", get(live))
54 .route("/audit/{id}/unlock", post(unlock))
55}
56
57pub fn require_cloud(state: &AppState) -> Result<(), AppError> {
59 if state.config.mode == Mode::Cloud {
60 Ok(())
61 } else {
62 Err(AppError::NotFound)
63 }
64}
65
66fn claim_tokens(headers: &HeaderMap) -> Vec<String> {
68 session::cookie(headers, CLAIM_COOKIE)
69 .map(|v| {
70 v.split('.')
71 .filter(|t| !t.is_empty())
72 .map(str::to_owned)
73 .collect()
74 })
75 .unwrap_or_default()
76}
77
78fn with_claim_token(mut tokens: Vec<String>, new: &str) -> String {
80 tokens.push(new.to_owned());
81 let skip = tokens.len().saturating_sub(MAX_CLAIM_TOKENS);
82 tokens[skip..].join(".")
83}
84
85pub fn clear_claim_cookie(state: &AppState) -> HeaderValue {
86 session::set_cookie(CLAIM_COOKIE, "", 0, state.config.secure_cookies())
87}
88
89#[derive(Deserialize)]
90pub struct StartForm {
91 url: String,
92 #[serde(rename = "cf-turnstile-response", default)]
94 turnstile: Option<String>,
95}
96
97async fn start(
98 State(state): State<AppState>,
99 hx: Hx,
100 headers: HeaderMap,
101 ClientIp(ip): ClientIp,
102 Form(form): Form<StartForm>,
103) -> Result<Response, AppError> {
104 require_cloud(&state)?;
105 let target = parse_start_url(&form.url).and_then(|u| check_public_target(&u).map(|()| u));
106 let url = match target {
107 Ok(u) => u,
108 Err(message) => {
109 return super::landing::refuse(&state, &form.url, StatusCode::BAD_REQUEST, message);
110 }
111 };
112 match turnstile::verify(&state, form.turnstile.as_deref(), ip).await {
113 Verdict::Passed => {}
114 Verdict::Failed => {
115 return super::landing::refuse(
116 &state,
117 &form.url,
118 StatusCode::FORBIDDEN,
119 "We couldn't confirm you're a human. Reload the page and try again.".to_owned(),
120 );
121 }
122 Verdict::Unavailable => {
123 return super::landing::refuse(
124 &state,
125 &form.url,
126 StatusCode::SERVICE_UNAVAILABLE,
127 "Verification is unavailable right now. Please try again in a minute.".to_owned(),
128 );
129 }
130 }
131 let domain = url.host_str().unwrap_or_default().to_ascii_lowercase();
132 let today = time::OffsetDateTime::now_utc().date();
135 let hash_for = |day: time::Date| ip.map(|ip| abuse::ip_hash(&state.config.secret_key, ip, day));
136 let ip_hash = hash_for(today);
137 let previous_ip_hash = today.previous_day().and_then(hash_for);
138
139 let claim_token = session::random_token();
140 let outcome = quick::start(
141 &state.pool,
142 &StartRequest {
143 domain: &domain,
144 start_url: url.as_str(),
145 claim_hash: &session::hash(&claim_token),
146 ip_hash: ip_hash.as_deref(),
147 previous_ip_hash: previous_ip_hash.as_deref(),
148 limits: Limits::DEFAULT,
149 source: Source::Web,
150 agent_daily_budget: None,
151 },
152 )
153 .await?;
154 let (crawl_id, how, fresh) = match outcome {
155 StartOutcome::Started { crawl_id } => (crawl_id, "started", true),
156 StartOutcome::Cached { crawl_id } => (crawl_id, "cached", false),
157 StartOutcome::Joined { crawl_id } => (crawl_id, "joined", false),
158 StartOutcome::AgentBudgetReached { .. } => {
160 return super::landing::refuse(
161 &state,
162 &form.url,
163 StatusCode::SERVICE_UNAVAILABLE,
164 "We can't start another audit right now. Please try again in a few minutes."
165 .to_owned(),
166 );
167 }
168 StartOutcome::Limited {
169 window,
170 retry_after_secs,
171 } => {
172 let (limit, per) = match window {
173 LimitWindow::Hour => (Limits::DEFAULT.per_hour, "an hour"),
174 LimitWindow::Day => (Limits::DEFAULT.per_day, "a day"),
175 };
176 let message = format!(
177 "The limit is {limit} audits {per} for each visitor, and you've used them. \
178 Try again in {}, or sign in to monitor your own site.",
179 abuse::wait_text(retry_after_secs)
180 );
181 let mut res =
182 super::landing::refuse(&state, &form.url, StatusCode::TOO_MANY_REQUESTS, message)?;
183 res.headers_mut().insert(
184 header::RETRY_AFTER,
185 HeaderValue::from(retry_after_secs.max(1)),
186 );
187 return Ok(res);
188 }
189 };
190 events::record(
191 &state.pool,
192 EventKind::AuditStarted,
193 None,
194 None,
195 Some(json!({ "crawl_id": crawl_id, "domain": domain, "outcome": how })),
196 )
197 .await?;
198
199 let to = format!("/audit/{crawl_id}");
200 let cookies: Vec<(HeaderName, HeaderValue)> = if fresh {
202 vec![(
203 header::SET_COOKIE,
204 session::set_cookie(
205 CLAIM_COOKIE,
206 &with_claim_token(claim_tokens(&headers), &claim_token),
207 CLAIM_TTL_SECS,
208 state.config.secure_cookies(),
209 ),
210 )]
211 } else {
212 Vec::new()
213 };
214 Ok(if hx.request {
215 (AppendHeaders(cookies), [hx_redirect(&to)]).into_response()
216 } else {
217 (AppendHeaders(cookies), Redirect::to(&to)).into_response()
218 })
219}
220
221async fn load(state: &AppState, raw_id: &str) -> Result<Audit, AppError> {
223 require_cloud(state)?;
224 let id = Uuid::parse_str(raw_id).map_err(|_| AppError::NotFound)?;
225 quick::get(&state.pool, id).await?.ok_or(AppError::NotFound)
226}
227
228pub struct IssueLine {
230 pub severity: &'static str,
231 pub label: &'static str,
232 pub title: &'static str,
233 pub detail: String,
235}
236
237pub struct DoneView {
238 pub score: i16,
239 pub tone: &'static str,
241 pub checks_chip: String,
242 pub pages_crawled: String,
244 pub stop_chip: Option<String>,
245 pub issues: Vec<IssueLine>,
246 pub locked: Option<String>,
248 pub all_clear: bool,
249 pub rankorg: String,
251}
252
253pub struct Notice {
255 pub title: &'static str,
256 pub message: String,
257}
258
259pub struct MainView {
261 pub id: Uuid,
262 pub domain: String,
263 pub start_url: Option<String>,
266 pub polling: bool,
268 pub running: bool,
270 pub pages_done: u32,
271 pub line: Option<String>,
273 pub done: Option<DoneView>,
274 pub notice: Option<Notice>,
275}
276
277pub struct UnlockCard {
279 pub id: Uuid,
280 pub email: String,
281 pub error: Option<String>,
282 pub sent: bool,
283}
284
285impl UnlockCard {
286 pub fn new(id: Uuid) -> UnlockCard {
287 UnlockCard {
288 id,
289 email: String::new(),
290 error: None,
291 sent: false,
292 }
293 }
294}
295
296#[derive(Template)]
297#[template(path = "quick/page.html")]
298pub struct ReportPage {
299 pub main: MainView,
300 pub unlock: UnlockCard,
301}
302
303#[derive(Template)]
304#[template(path = "quick/main.html")]
305pub struct MainPartial {
306 pub main: MainView,
307 pub unlock: UnlockCard,
308}
309
310#[derive(Template)]
311#[template(path = "quick/unlock.html")]
312pub struct UnlockPartial {
313 pub unlock: UnlockCard,
314}
315
316fn line_text(position: Option<i64>) -> Option<String> {
318 match position? {
319 n if n <= 1 => Some("You're next in line.".to_owned()),
320 n => Some(format!("You're #{n} in line.")),
321 }
322}
323
324async fn main_view(state: &AppState, audit: &Audit) -> Result<MainView, AppError> {
325 let mut view = build_main(audit);
326 if view.polling && !view.running {
327 view.line = line_text(quick::queue_position(&state.pool, audit.crawl.id).await?);
328 }
329 Ok(view)
330}
331
332fn build_main(audit: &Audit) -> MainView {
333 let crawl = &audit.crawl;
334 let mut view = MainView {
335 id: crawl.id,
336 domain: audit.domain.clone(),
337 start_url: start_page(&audit.start_url, &audit.domain),
338 polling: false,
339 running: false,
340 pages_done: 0,
341 line: None,
342 done: None,
343 notice: None,
344 };
345 match crawl.status {
346 CrawlStatus::Queued | CrawlStatus::Running => {
347 view.polling = true;
348 view.running = crawl.status == CrawlStatus::Running;
349 view.pages_done = crawl.progress().map_or(0, |p| p.pages_done);
350 }
351 CrawlStatus::Failed | CrawlStatus::Done => {
352 view.notice = no_report_notice(crawl);
353 if view.notice.is_none()
354 && let (Some(summary), Some(score)) = (crawl.summary(), crawl.health_score)
355 {
356 view.done = Some(done_view(
357 crawl.id,
358 score,
359 crawl.checks_passed.unwrap_or(0),
360 crawl.checks_total.unwrap_or(0),
361 &summary,
362 ));
363 }
364 }
365 }
366 view
367}
368
369pub(crate) fn no_report_notice(crawl: &Crawl) -> Option<Notice> {
373 match crawl.status {
374 CrawlStatus::Queued | CrawlStatus::Running => None,
375 CrawlStatus::Failed => Some(failure_notice(crawl.failure_reason.as_deref())),
376 CrawlStatus::Done => match (crawl.summary(), crawl.health_score) {
377 (Some(summary), Some(_)) if summary.report_summary.pages > 0 => None,
378 (Some(summary), _) if matches!(summary.stop_reason, StopReason::RobotsBlocked) => {
379 Some(Notice {
380 title: "This site's robots.txt blocks crawlers",
381 message: "Its robots.txt forbids crawling the whole site, so CodoSEObot \
382 stayed out, as it always does. If you own the site and want an \
383 audit, allow CodoSEObot in robots.txt and try again."
384 .to_owned(),
385 })
386 }
387 _ => Some(Notice {
388 title: "We found nothing to audit",
389 message: "The crawl finished without finding any pages. Check the address \
390 and try again."
391 .to_owned(),
392 }),
393 },
394 }
395}
396
397fn start_page(start_url: &str, domain: &str) -> Option<String> {
399 (start_url != format!("https://{domain}/")).then(|| start_url.to_owned())
400}
401
402fn failure_notice(reason: Option<&str>) -> Notice {
403 let reason = reason.unwrap_or_default();
404 if reason.starts_with(codoseo_core::output::BLOCKED_REASON_PREFIX) {
405 Notice {
406 title: "This site blocked our crawler",
407 message: "It answered our requests with errors or a challenge page, so we couldn't \
408 audit it. If you own the site, allow CodoSEObot (see the bot page) and \
409 try again."
410 .to_owned(),
411 }
412 } else if reason.starts_with(codoseo_core::output::UNREACHABLE_REASON_PREFIX) {
413 Notice {
414 title: "We couldn't reach this site",
415 message: "It didn't answer, or the address doesn't exist. Check the address and \
416 that the site is online, then try again."
417 .to_owned(),
418 }
419 } else if reason.contains("address not allowed") {
420 Notice {
421 title: "That address can't be audited",
422 message: "It is private or internal, so we can't audit it.".to_owned(),
423 }
424 } else {
425 Notice {
426 title: "Something went wrong",
427 message: "An unexpected error happened on our side. Try again in a moment.".to_owned(),
428 }
429 }
430}
431
432fn done_view(
433 crawl_id: Uuid,
434 score: i16,
435 passed: i16,
436 total: i16,
437 summary: &codoseo_store::crawls::StoredSummary,
438) -> DoneView {
439 let mut failing: Vec<(CheckId, u32)> = summary
440 .counts
441 .iter()
442 .filter_map(|(slug, n)| Some((CheckId::from_slug(slug)?, *n)))
443 .collect();
444 failing.sort_by_key(|&(id, n)| (def(id).severity, std::cmp::Reverse(n), id));
445 let pages = u64::from(summary.report_summary.pages).max(1);
446 let issues = failing
447 .iter()
448 .take(PREVIEW_ISSUES)
449 .map(|&(id, n)| {
450 let d = def(id);
451 let (severity, label) = match d.severity {
452 Severity::Critical => ("critical", "Critical"),
453 Severity::Warning => ("warning", "Warning"),
454 Severity::Notice => ("notice", "Notice"),
455 };
456 IssueLine {
457 severity,
458 label,
459 title: d.title,
460 detail: format!(
461 "{} · {}",
462 fmt::pages(n),
463 fmt::percent(i64::from(n), pages as i64)
464 ),
465 }
466 })
467 .collect();
468 let more = failing.len().saturating_sub(PREVIEW_ISSUES);
469 let limits = PlanLimits::quick_audit();
470 let stop_chip = match &summary.stop_reason {
471 StopReason::PageLimit => Some(format!(
472 "Stopped at {} pages",
473 fmt::thousands(limits.max_pages.unwrap_or(100))
474 )),
475 StopReason::TimeLimit => Some(format!(
476 "Stopped at the {} minute limit",
477 limits.max_duration.map_or(2, |d| d.as_secs() / 60)
478 )),
479 _ => None,
480 };
481 DoneView {
482 score,
483 tone: match score {
484 s if s >= 90 => "c-ok",
485 s if s >= 70 => "c-warn",
486 _ => "c-err",
487 },
488 checks_chip: format!("{passed} of {total} checks passed"),
489 pages_crawled: format!("{} crawled", fmt::pages(summary.report_summary.pages)),
490 stop_chip,
491 issues,
492 locked: (more > 0)
493 .then(|| format!("{more} more issue{}", if more == 1 { "" } else { "s" })),
494 all_clear: failing.is_empty(),
495 rankorg: format!("/go/rankorg?src=audit&audit={crawl_id}"),
496 }
497}
498
499fn report_headers() -> [(HeaderName, &'static str); 2] {
501 [
502 (header::CACHE_CONTROL, "no-store"),
503 (HeaderName::from_static("x-robots-tag"), "noindex, nofollow"),
504 ]
505}
506
507async fn report(
508 State(state): State<AppState>,
509 Path(id): Path<String>,
510) -> Result<Response, AppError> {
511 let audit = load(&state, &id).await?;
512 let page = ReportPage {
513 main: main_view(&state, &audit).await?,
514 unlock: UnlockCard::new(audit.crawl.id),
515 };
516 Ok((report_headers(), html(&page)?).into_response())
517}
518
519async fn live(State(state): State<AppState>, Path(id): Path<String>) -> Result<Response, AppError> {
520 let audit = load(&state, &id).await?;
521 let part = MainPartial {
522 main: main_view(&state, &audit).await?,
523 unlock: UnlockCard::new(audit.crawl.id),
524 };
525 Ok((report_headers(), html(&part)?).into_response())
526}
527
528#[derive(Deserialize)]
529pub struct UnlockForm {
530 email: String,
531}
532
533async fn unlock(
534 State(state): State<AppState>,
535 hx: Hx,
536 Path(id): Path<String>,
537 Form(form): Form<UnlockForm>,
538) -> Result<Response, AppError> {
539 let audit = load(&state, &id).await?;
540 let mut card = UnlockCard::new(audit.crawl.id);
541 card.email = form.email.trim().to_owned();
542
543 let mut status = StatusCode::OK;
544 match email::parse(&form.email) {
545 None => card.error = Some("That doesn't look like an email address.".to_owned()),
546 Some(address) if abuse::is_disposable(address) => {
547 card.error = Some(
548 "Please use a permanent email address. Throwaway inboxes can't keep your \
549 report or your alerts."
550 .to_owned(),
551 );
552 }
553 Some(address) => {
554 let address = address.to_owned();
555 let outcome = magic::issue_link(
556 &state,
557 &address,
558 "/",
559 Some(AuditLink {
560 crawl_id: audit.crawl.id,
561 domain: &audit.domain,
562 }),
563 )
564 .await?;
565 match outcome {
566 LinkOutcome::Sent => {
567 events::record(
568 &state.pool,
569 EventKind::EmailGiven,
570 None,
571 Some(audit.site_id),
572 Some(json!({ "crawl_id": audit.crawl.id })),
573 )
574 .await?;
575 card.sent = true;
576 }
577 LinkOutcome::Throttled(slot) => {
578 status = StatusCode::TOO_MANY_REQUESTS;
579 card.error = Some(
580 match slot {
581 UnlockSlot::AddressCapReached => {
582 "We've already sent several emails to that address recently. \
583 Check your inbox and spam folder, or try again in an hour."
584 }
585 _ => {
586 "We already sent several links for this audit. Check your \
587 inbox and spam folder, or try again in an hour."
588 }
589 }
590 .to_owned(),
591 );
592 }
593 }
594 card.email = address;
595 }
596 }
597
598 if hx.request {
599 return Ok((status, html(&UnlockPartial { unlock: card })?).into_response());
600 }
601 let page = ReportPage {
602 main: main_view(&state, &audit).await?,
603 unlock: card,
604 };
605 Ok((status, report_headers(), html(&page)?).into_response())
606}
607
608pub async fn attach_after_login(
611 state: &AppState,
612 account: &Account,
613 audit_id: Uuid,
614 headers: &HeaderMap,
615) -> Result<String, AppError> {
616 let claim_hashes: Vec<Vec<u8>> = claim_tokens(headers)
617 .iter()
618 .map(|t| session::hash(t))
619 .collect();
620 let limits = PlanLimits::for_plan(account.plan);
621 let outcome = quick::claim(
622 &state.pool,
623 account.id,
624 audit_id,
625 &claim_hashes,
626 limits.max_sites.map(i64::from),
627 FIRST_CRAWL_PRIORITY,
628 schedule_for(account.plan),
629 )
630 .await?;
631 let (how, site) = match outcome {
632 ClaimOutcome::Attached(s) => ("attached", Some(s)),
633 ClaimOutcome::Created(s) => ("created", Some(s)),
634 ClaimOutcome::Existing(s) => ("existing", Some(s)),
635 ClaimOutcome::LimitReached(s) => ("limit", s),
636 ClaimOutcome::NotFound => ("expired", None),
637 };
638 if matches!(how, "attached" | "created")
639 && let Some(s) = &site
640 {
641 super::settings_alerts::default_rules_for_site(state, account.id, s.id).await;
642 }
643 events::record(
644 &state.pool,
645 EventKind::LinkClicked,
646 Some(account.id),
647 site.as_ref().map(|s| s.id),
648 Some(json!({ "crawl_id": audit_id, "outcome": how })),
649 )
650 .await?;
651 Ok(match site {
652 Some(s) => format!("/s/{}/audit", s.id),
653 None => "/".to_owned(),
654 })
655}