Skip to main content

codoseo_web/agent/
anon.rs

1//! The no-key tier of the cloud MCP server: what [`AnonBackend`](codoseo_mcp::cloud::AnonBackend)
2//! does over the same store the website's no-signup audit uses. Nothing here is charged to an
3//! API key; abuse is held back by the limits of `store::quick`: one fresh audit per domain per
4//! 24 hours, a daily budget over all agents, the per-IP limits (shared with the website, for
5//! clients that connect directly) and the start-monitoring email caps.
6//!
7//! Errors are `String`s, the message an agent reads as a tool error. Database trouble is logged
8//! and read as the generic message.
9
10use std::net::IpAddr;
11
12use axum::http::{HeaderMap, header};
13use codoseo_checks::def;
14use codoseo_mcp::cloud::types::{
15    AuditIssueUrls, MonitoringRequested, QuickAuditState, QuickAuditSummary,
16};
17use codoseo_store::crawls::CrawlStatus;
18use codoseo_store::events::{self, EventKind};
19use codoseo_store::quick::{
20    self, LimitWindow, Limits, MonitoringCaps, MonitoringSlot, Requester, Source, StartOutcome,
21    StartRequest,
22};
23use serde_json::json;
24use uuid::Uuid;
25
26use super::error::AgentError;
27use super::service::{crawl_health, issue_page, parse_check};
28use crate::abuse;
29use crate::auth::mailer::Email;
30use crate::auth::{email, session};
31use crate::routes::quick::no_report_notice;
32use crate::routes::sites::{check_public_target, parse_start_url};
33use crate::state::AppState;
34
35/// How long a start-monitoring link works. Longer than a sign-in link: the person has to see
36/// the email, which an agent's user may do later.
37pub const START_TTL: time::Duration = time::Duration::hours(24);
38
39const GENERIC: &str = "Something went wrong on our side. Try again in a moment.";
40const NO_SUCH_AUDIT: &str =
41    "No such audit. Use the audit_id that quick_audit returned (it is the id of a free audit).";
42
43/// What is known about a caller without a key, from the HTTP request.
44#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct AnonCaller {
46    /// The client's address, when it can be told (see [`abuse::client_ip`]).
47    pub ip: Option<IpAddr>,
48    /// A hosted connector (claude.ai, ChatGPT) calling from servers shared by many people, by
49    /// its `User-Agent`. It skips the per-IP limits, which would lock everyone out at once.
50    pub shared_client: bool,
51}
52
53impl AnonCaller {
54    pub fn from_request(state: &AppState, headers: &HeaderMap, peer: Option<IpAddr>) -> AnonCaller {
55        let user_agent = headers
56            .get(header::USER_AGENT)
57            .and_then(|v| v.to_str().ok());
58        AnonCaller {
59            ip: abuse::client_ip(headers, peer, &state.config),
60            shared_client: state.config.mcp.is_shared_client(user_agent),
61        }
62    }
63
64    /// The hashes the per-IP limits count by (today's and yesterday's salt). None for a shared
65    /// client and for a request whose address can't be told: nothing per IP applies then.
66    fn ip_hashes(&self, state: &AppState) -> Option<(Vec<u8>, Option<Vec<u8>>)> {
67        if self.shared_client {
68            return None;
69        }
70        let ip = self.ip?;
71        let today = time::OffsetDateTime::now_utc().date();
72        let hash = |day: time::Date| abuse::ip_hash(&state.config.secret_key, ip, day);
73        Some((hash(today), today.previous_day().map(hash)))
74    }
75}
76
77pub struct AnonService<'a> {
78    state: &'a AppState,
79}
80
81fn internal(e: impl std::fmt::Display) -> String {
82    tracing::error!(error = %e, "no-key mcp tool failed");
83    GENERIC.to_owned()
84}
85
86fn db(e: sqlx::Error) -> String {
87    if crate::error::is_unavailable(&e) {
88        AgentError::Unavailable.message()
89    } else {
90        internal(e)
91    }
92}
93
94/// A site address as the agent typed it, or why it can't be used.
95fn public_target(raw: &str) -> Result<url::Url, String> {
96    parse_start_url(raw).and_then(|u| check_public_target(&u).map(|()| u))
97}
98
99impl<'a> AnonService<'a> {
100    pub fn new(state: &'a AppState) -> AnonService<'a> {
101        AnonService { state }
102    }
103
104    /// Counts one call of a direct client against its per-minute allowance (shared connectors
105    /// and callers whose address can't be told are not limited here).
106    fn throttle(&self, who: &AnonCaller) -> Result<(), String> {
107        let Some((hash, _)) = who.ip_hashes(self.state) else {
108            return Ok(());
109        };
110        self.state
111            .anon_calls
112            .check(&hash, std::time::Instant::now())
113            .map_err(|wait| {
114                format!(
115                    "Too many calls from this client: the limit is {} a minute. Wait {} seconds \
116                     and try again.",
117                    super::limiter::ANON_CALLS_PER_WINDOW,
118                    wait.as_secs() + 1
119                )
120            })
121    }
122
123    /// `{BASE_URL}{path}`.
124    fn link(&self, path: &str) -> String {
125        let mut link = self.state.config.base_url.clone();
126        link.set_path(path);
127        link.to_string()
128    }
129
130    // ---- quick_audit, get_audit, get_issue_urls ----
131
132    pub async fn quick_audit(
133        &self,
134        who: &AnonCaller,
135        raw_url: &str,
136    ) -> Result<QuickAuditState, String> {
137        self.throttle(who)?;
138        let state = self.state;
139        let url = public_target(raw_url)?;
140        let domain = url.host_str().unwrap_or_default().to_ascii_lowercase();
141        let hashes = who.ip_hashes(state);
142        // Nobody holds this claim token: an agent's audit can't be attached by cookie, only
143        // read by its id.
144        let claim_token = session::random_token();
145        let outcome = quick::start(
146            &state.pool,
147            &StartRequest {
148                domain: &domain,
149                start_url: url.as_str(),
150                claim_hash: &session::hash(&claim_token),
151                ip_hash: hashes.as_ref().map(|(today, _)| today.as_slice()),
152                previous_ip_hash: hashes.as_ref().and_then(|(_, prev)| prev.as_deref()),
153                limits: Limits::DEFAULT,
154                source: Source::Agent,
155                agent_daily_budget: Some(state.config.mcp.daily_audits),
156            },
157        )
158        .await
159        .map_err(db)?;
160        let outcome_is_fresh = matches!(outcome, StartOutcome::Started { .. });
161        let (crawl_id, how) = match outcome {
162            StartOutcome::Started { crawl_id } => (crawl_id, "started"),
163            StartOutcome::Cached { crawl_id } => (crawl_id, "cached"),
164            StartOutcome::Joined { crawl_id } => (crawl_id, "joined"),
165            StartOutcome::AgentBudgetReached { retry_after_secs } => {
166                return Err(format!(
167                    "CodoSEO's free audits for AI assistants are used up for now. Try again in \
168                     {}, or ask the user to run the audit at {} themselves.",
169                    abuse::wait_text(retry_after_secs),
170                    self.link("/"),
171                ));
172            }
173            StartOutcome::Limited {
174                window,
175                retry_after_secs,
176            } => {
177                let (limit, per) = match window {
178                    LimitWindow::Hour => (Limits::DEFAULT.per_hour, "an hour"),
179                    LimitWindow::Day => (Limits::DEFAULT.per_day, "a day"),
180                };
181                return Err(format!(
182                    "The limit is {limit} new audits {per} for each client address, and this \
183                     one has used them. Try again in {}. Audits of sites audited in the last \
184                     24 hours are not counted.",
185                    abuse::wait_text(retry_after_secs)
186                ));
187            }
188        };
189        // Only a fresh audit is a funnel step: repeats of cached and joined ones are free to
190        // ask for, and must not each leave a row in a table nothing prunes. The audit exists
191        // either way, so a failure to note it is logged and the agent still gets its id.
192        if outcome_is_fresh
193            && let Err(error) = events::record(
194                &state.pool,
195                EventKind::AuditStarted,
196                None,
197                None,
198                Some(json!({
199                    "crawl_id": crawl_id, "domain": domain, "outcome": how, "source": "agent",
200                })),
201            )
202            .await
203        {
204            tracing::error!(%error, %crawl_id, "could not record the audit_started event");
205        }
206        self.state_of(crawl_id).await
207    }
208
209    pub async fn get_audit(
210        &self,
211        who: &AnonCaller,
212        audit_id: &str,
213    ) -> Result<QuickAuditState, String> {
214        self.throttle(who)?;
215        let id = Uuid::parse_str(audit_id.trim()).map_err(|_| NO_SUCH_AUDIT.to_owned())?;
216        self.state_of(id).await
217    }
218
219    /// `get_audit` without the per-client throttle, for `quick_audit`'s wait loop.
220    pub async fn poll_audit(&self, audit_id: &str) -> Result<QuickAuditState, String> {
221        let id = Uuid::parse_str(audit_id.trim()).map_err(|_| NO_SUCH_AUDIT.to_owned())?;
222        self.state_of(id).await
223    }
224
225    /// Where the audit stands. A queued or running one is a single small read; the score, the
226    /// failing checks and their example URLs are only worked out once it has ended.
227    async fn state_of(&self, id: Uuid) -> Result<QuickAuditState, String> {
228        let (status, pages_done) = quick::status(&self.state.pool, id)
229            .await
230            .map_err(db)?
231            .ok_or_else(|| NO_SUCH_AUDIT.to_owned())?;
232        if matches!(status, CrawlStatus::Queued | CrawlStatus::Running) {
233            let line = match status {
234                CrawlStatus::Queued => "Waiting for a crawler. ",
235                _ => "The crawl is running. ",
236            };
237            return Ok(QuickAuditState::Running {
238                audit_id: id,
239                pages_done,
240                message: format!("{line}Call get_audit with this audit_id again in a few seconds."),
241            });
242        }
243        let audit = self.audit(&id.to_string()).await?;
244        let crawl = &audit.crawl;
245        if let Some(notice) = no_report_notice(crawl) {
246            return Ok(QuickAuditState::Failed {
247                audit_id: id,
248                reason: format!("{}. {}", notice.title, notice.message),
249            });
250        }
251        let health = crawl_health(&self.state.pool, crawl)
252            .await
253            .map_err(|e| match e {
254                AgentError::Unavailable => e.message(),
255                other => internal(other),
256            })?;
257        Ok(QuickAuditState::Done(Box::new(
258            QuickAuditSummary {
259                audit_id: id,
260                domain: audit.domain.clone(),
261                start_url: audit.start_url.clone(),
262                health_score: health.health_score,
263                checks_passed: health.checks_passed,
264                checks_total: health.checks_total,
265                pages_crawled: health.pages_crawled,
266                stop_reason: health.stop_reason,
267                stop_code: health.stop_code,
268                failing_checks: health.failing_checks.into_iter().map(Into::into).collect(),
269                more_failing_checks: health.more_failing_checks,
270                report_url: self.link(&format!("/audit/{id}")),
271                note: format!(
272                    "To monitor {} every week and get an email when something breaks, call \
273                     start_monitoring with the site's URL and the owner's email address.",
274                    audit.domain
275                ),
276            }
277            .fit(),
278        )))
279    }
280
281    pub async fn audit_issue_urls(
282        &self,
283        who: &AnonCaller,
284        audit_id: &str,
285        check: &str,
286        limit: Option<u32>,
287        offset: Option<u32>,
288    ) -> Result<AuditIssueUrls, String> {
289        self.throttle(who)?;
290        let audit = self.audit(audit_id).await?;
291        let check = parse_check(check).map_err(|e| e.message())?;
292        if audit.crawl.status != CrawlStatus::Done || no_report_notice(&audit.crawl).is_some() {
293            return Err(match audit.crawl.status {
294                CrawlStatus::Queued | CrawlStatus::Running => {
295                    "That audit has not finished yet. Call get_audit until it is done.".to_owned()
296                }
297                _ => "That audit ended without a report, so there are no pages to list.".to_owned(),
298            });
299        }
300        let rows = issue_page(&self.state.pool, audit.crawl.id, check, limit, offset)
301            .await
302            .map_err(|e| match e {
303                AgentError::Unavailable => e.message(),
304                other => internal(other),
305            })?;
306        Ok(AuditIssueUrls {
307            audit_id: audit.crawl.id,
308            check,
309            title: def(check).title.to_owned(),
310            total: rows.total,
311            limit: rows.limit,
312            offset: rows.offset,
313            urls: rows.urls,
314            next_offset: rows.next_offset,
315        })
316    }
317
318    /// The quick audit with this id. Every id that isn't one (unknown, malformed, another
319    /// kind of crawl) reads the same.
320    async fn audit(&self, audit_id: &str) -> Result<quick::Audit, String> {
321        let id = Uuid::parse_str(audit_id.trim()).map_err(|_| NO_SUCH_AUDIT.to_owned())?;
322        quick::get(&self.state.pool, id)
323            .await
324            .map_err(db)?
325            .ok_or_else(|| NO_SUCH_AUDIT.to_owned())
326    }
327
328    // ---- start_monitoring ----
329
330    pub async fn start_monitoring(
331        &self,
332        who: &AnonCaller,
333        raw_url: &str,
334        raw_email: &str,
335    ) -> Result<MonitoringRequested, String> {
336        self.throttle(who)?;
337        let state = self.state;
338        let url = public_target(raw_url)?;
339        let address = email::parse(raw_email)
340            .ok_or_else(|| "That doesn't look like an email address.".to_owned())?
341            .to_owned();
342        if abuse::is_disposable(&address) {
343            return Err(
344                "Please use a permanent email address. Throwaway inboxes can't keep \
345                        a site's alerts."
346                    .to_owned(),
347            );
348        }
349        let domain = url.host_str().unwrap_or_default().to_ascii_lowercase();
350        let canonical = email::canonical(&address);
351        let token = session::random_token();
352        let hashes = who.ip_hashes(state);
353        let slot = quick::create_monitoring_token(
354            &state.pool,
355            &canonical,
356            hashes.as_ref().map(|(today, prev)| Requester {
357                ip_hash: today,
358                previous_ip_hash: prev.as_deref(),
359            }),
360            &session::hash(&token),
361            json!({
362                "email": address,
363                "canonical": canonical,
364                "start_url": url.as_str(),
365                "domain": domain,
366                "source": "agent",
367            }),
368            START_TTL,
369            MonitoringCaps::with_daily(state.config.mcp.daily_emails),
370        )
371        .await
372        .map_err(db)?;
373        match slot {
374            MonitoringSlot::Created => {}
375            MonitoringSlot::AddressDayCapReached => {
376                return Err(
377                    "We already sent that address its emails for today. Ask the user \
378                            to check their inbox and spam folder, or try again tomorrow."
379                        .to_owned(),
380                );
381            }
382            MonitoringSlot::HourlyCapReached => {
383                return Err(
384                    "CodoSEO has sent as many monitoring emails as it allows for AI \
385                            assistants this hour. Try again in an hour."
386                        .to_owned(),
387                );
388            }
389            MonitoringSlot::AddressCapReached => {
390                return Err(
391                    "We already sent several emails to that address in the last hour. \
392                            Ask the user to check their inbox and spam folder, or try again in \
393                            an hour."
394                        .to_owned(),
395                );
396            }
397            MonitoringSlot::IpCapReached => {
398                return Err(
399                    "This client has asked for several monitoring emails in the last \
400                            hour, which is the limit. Try again in an hour."
401                        .to_owned(),
402                );
403            }
404            MonitoringSlot::DailyCapReached => {
405                return Err(
406                    "CodoSEO has sent all the monitoring emails it allows for AI \
407                            assistants today. Try again tomorrow, or ask the user to add the \
408                            site at the website."
409                        .to_owned(),
410                );
411            }
412        }
413
414        let link = self.link(&format!("/monitoring/start/{token}"));
415        let text = format!(
416            "An AI assistant you are working with asked CodoSEO to monitor {domain}.\n\n\
417             To confirm, open this link and press the button:\n\n{link}\n\n\
418             CodoSEO then starts weekly monitoring on your free CodoSEO account (one is created \
419             if you don't have one), crawls {domain} now, and emails you when something \
420             important breaks. It also shows you an API key once, which lets your assistant \
421             read your site's results.\n\n\
422             The link works once and expires in 24 hours. If you didn't ask for this, ignore \
423             this email and nothing happens."
424        );
425        if let Err(e) = state
426            .mailer
427            .send(Email {
428                to: address.clone(),
429                subject: format!("Confirm monitoring for {domain}"),
430                text,
431                html: None,
432            })
433            .await
434        {
435            // The answer is the same either way; a broken mail server is for the operator.
436            tracing::error!(error = %e, "could not send the start-monitoring link");
437        }
438        // The email is out, so the agent is told it was sent even when this note can't be kept:
439        // an error here would make it ask again and mail the address a second time.
440        if let Err(error) = events::record(
441            &state.pool,
442            EventKind::EmailGiven,
443            None,
444            None,
445            Some(json!({ "source": "agent", "domain": domain })),
446        )
447        .await
448        {
449            tracing::error!(%error, "could not record the email_given event");
450        }
451        Ok(MonitoringRequested {
452            status: "confirmation_sent".to_owned(),
453            domain: domain.clone(),
454            message: format!(
455                "We emailed a confirmation link to {address}. Tell the user to open it and \
456                 press the button within 24 hours. Then CodoSEO crawls {domain} every week, \
457                 emails them when something important breaks, and shows them an API key to \
458                 connect you to their own data. Nothing starts until they confirm."
459            ),
460        })
461    }
462}