1use 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
35pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
45pub struct AnonCaller {
46 pub ip: Option<IpAddr>,
48 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 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
94fn 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 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 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 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 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 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 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 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 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 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 tracing::error!(error = %e, "could not send the start-monitoring link");
437 }
438 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}