Skip to main content

codoseo_web/routes/
quick.rs

1//! The no-signup audit (cloud only): `POST /audit` starts a 100-page quick crawl, `/audit/{id}`
2//! is the public report that walks from waiting through running to a score and the top five
3//! issues, and `POST /audit/{id}/unlock` emails a sign-in link that attaches the audited site to
4//! a new account and queues its first full crawl.
5//!
6//! The report is keyed by the crawl id, so one cached report serves everyone who audits the
7//! same domain within 24 hours. Only `quick` crawls are ever served here.
8
9use 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";
41/// Unclaimed audits live 7 days (spec section 6), and so does the cookie.
42const CLAIM_TTL_SECS: i64 = 7 * 24 * 3600;
43/// A visitor can audit several sites before unlocking one, so the cookie holds the claim tokens
44/// of their last few audits, joined with `.` (tokens are URL-safe base64, which has no dot).
45const MAX_CLAIM_TOKENS: usize = 5;
46/// How many issues the preview shows; the rest are counted and locked.
47const 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
57/// The no-signup audit, landing page, bot page and robots.txt exist on the cloud only.
58pub 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
66/// The claim tokens in the visitor's cookie, oldest first.
67fn 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
78/// The cookie value after adding `new`: the last [`MAX_CLAIM_TOKENS`] tokens.
79fn 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    /// Added to the form by Cloudflare's Turnstile script.
93    #[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    // Today's hash is stored; yesterday's is also counted, since a limit window can span
133    // midnight and the salt changes then.
134    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        // The website sets no agent budget, so this never happens here; say so if it does.
159        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    // Only the visitor whose submit created the audit holds its claim token.
201    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
221/// The audit for `raw_id`, or the 404 every unservable id gets.
222async 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
228/// A line of the preview's issue list.
229pub struct IssueLine {
230    pub severity: &'static str,
231    pub label: &'static str,
232    pub title: &'static str,
233    /// `12 pages · 4.8%`
234    pub detail: String,
235}
236
237pub struct DoneView {
238    pub score: i16,
239    /// `c-ok`, `c-warn` or `c-err`.
240    pub tone: &'static str,
241    pub checks_chip: String,
242    /// `1,284 pages crawled`
243    pub pages_crawled: String,
244    pub stop_chip: Option<String>,
245    pub issues: Vec<IssueLine>,
246    /// `3 more issues`, when there are more than the preview shows.
247    pub locked: Option<String>,
248    pub all_clear: bool,
249    /// The counted link to RankOrg.
250    pub rankorg: String,
251}
252
253/// A report that can't show a score, and why.
254pub struct Notice {
255    pub title: &'static str,
256    pub message: String,
257}
258
259/// What `#audit-main` shows.
260pub struct MainView {
261    pub id: Uuid,
262    pub domain: String,
263    /// The page the audit started from, when it isn't the site's homepage: reports are shared
264    /// per domain for 24 hours, so a visitor can see another start page's audit.
265    pub start_url: Option<String>,
266    /// Waiting or running: the block polls itself every 2 s.
267    pub polling: bool,
268    /// Waiting vs running, for the headline.
269    pub running: bool,
270    pub pages_done: u32,
271    /// `You're #3 in line.`, while waiting behind other audits.
272    pub line: Option<String>,
273    pub done: Option<DoneView>,
274    pub notice: Option<Notice>,
275}
276
277/// The unlock card's state.
278pub 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
316/// The reader's place in the queue as a sentence. Position 1 is next.
317fn 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
369/// Why an ended audit has no score to show: it failed, its robots.txt blocks crawlers, or it
370/// found no pages. `None` while it is waiting or running, and when it has a report. The website
371/// and the no-key MCP tools say the same thing.
372pub(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
397/// The start URL when it is more than `https://{domain}/`.
398fn 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
499/// Headers the public report always carries: it is for one visitor, not for search engines.
500fn 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
608/// After a sign-in link from an audit is used: gives the account the audited site (see
609/// [`quick::claim`]) and returns where to send them.
610pub 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}