1use std::io;
9
10use axum::Router;
11use axum::body::{Body, Bytes};
12use axum::extract::{Path, Query, State};
13use axum::http::{HeaderValue, header};
14use axum::response::{IntoResponse, Response};
15use axum::routing::get;
16use codoseo_core::check::CheckId;
17use codoseo_core::page::Indexability;
18use codoseo_store::explorer::PageFilter;
19use codoseo_store::export::ExportRow;
20use futures_util::{Stream, stream};
21use serde::Deserialize;
22use sqlx::PgPool;
23use time::{Date, OffsetDateTime};
24use uuid::Uuid;
25
26use crate::auth::{CurrentUser, load_site};
27use crate::error::AppError;
28use crate::state::AppState;
29
30pub fn routes() -> Router<AppState> {
31 Router::new().route("/s/{site}/export.csv", get(export))
32}
33
34const BATCH: i64 = 1000;
36
37const COLUMNS: [&str; 22] = [
38 "Address",
39 "Status",
40 "Indexability",
41 "Content type",
42 "Title",
43 "Title length",
44 "Meta description",
45 "Description length",
46 "H1",
47 "Canonical",
48 "Meta robots",
49 "X-Robots-Tag",
50 "Word count",
51 "Depth",
52 "Inlinks",
53 "Outlinks (internal)",
54 "Outlinks (external)",
55 "Response time (ms)",
56 "Size (bytes)",
57 "In sitemap",
58 "Redirect target",
59 "Issues",
60];
61
62#[derive(Deserialize)]
63struct ExportQuery {
64 filter: Option<String>,
65 q: Option<String>,
66}
67
68async fn export(
69 State(state): State<AppState>,
70 user: CurrentUser,
71 Path(site): Path<String>,
72 Query(query): Query<ExportQuery>,
73) -> Result<Response, AppError> {
74 let site_id = Uuid::parse_str(&site).map_err(|_| AppError::NotFound)?;
75 let site = load_site(&state, &user, site_id).await?;
76 let crawl = codoseo_store::crawls::latest_done(&state.pool, site.id)
77 .await?
78 .ok_or_else(|| {
79 AppError::BadRequest("There's no finished crawl to export yet.".to_owned())
80 })?;
81 let filter = PageFilter::parse(query.filter.as_deref().unwrap_or_default());
82 let q = query
83 .q
84 .map(|q| q.trim().to_owned())
85 .filter(|q| !q.is_empty());
86 let day = crawl
87 .finished_at
88 .unwrap_or_else(OffsetDateTime::now_utc)
89 .date();
90 let disposition = format!(
91 "attachment; filename=\"{}\"",
92 file_name(&site.domain, filter, day)
93 );
94
95 let cursor = Cursor {
96 pool: state.pool.clone(),
97 crawl_id: crawl.id,
98 filter,
99 q,
100 after: 0,
101 header: true,
102 done: false,
103 };
104 Ok((
105 [
106 (
107 header::CONTENT_TYPE,
108 HeaderValue::from_static("text/csv; charset=utf-8"),
109 ),
110 (
111 header::CONTENT_DISPOSITION,
112 HeaderValue::from_str(&disposition).map_err(AppError::internal)?,
113 ),
114 (header::CACHE_CONTROL, HeaderValue::from_static("no-store")),
115 ],
116 Body::from_stream(chunks(cursor)),
117 )
118 .into_response())
119}
120
121fn file_name(domain: &str, filter: PageFilter, day: Date) -> String {
124 let safe = |s: &str| -> String {
125 s.chars()
126 .map(|c| {
127 if c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_') {
128 c
129 } else {
130 '-'
131 }
132 })
133 .collect()
134 };
135 format!(
136 "{}-{}-{:04}-{:02}-{:02}.csv",
137 safe(domain),
138 safe(&filter.key()),
139 day.year(),
140 u8::from(day.month()),
141 day.day()
142 )
143}
144
145struct Cursor {
147 pool: PgPool,
148 crawl_id: Uuid,
149 filter: PageFilter,
150 q: Option<String>,
151 after: i64,
153 header: bool,
155 done: bool,
156}
157
158fn chunks(cursor: Cursor) -> impl Stream<Item = Result<Bytes, io::Error>> + Send + 'static {
162 stream::unfold(cursor, |mut c| async move {
163 if c.done {
164 return None;
165 }
166 let rows = match codoseo_store::export::batch(
167 &c.pool,
168 c.crawl_id,
169 c.filter,
170 c.q.as_deref(),
171 c.after,
172 BATCH,
173 )
174 .await
175 {
176 Ok(rows) => rows,
177 Err(e) => {
178 tracing::error!(error = %e, crawl_id = %c.crawl_id, "CSV export failed partway");
179 c.done = true;
180 return Some((Err(io::Error::other(e)), c));
181 }
182 };
183 if rows.is_empty() && !c.header {
184 return None;
185 }
186 c.done = (rows.len() as i64) < BATCH;
187 if let Some(last) = rows.last() {
188 c.after = last.id;
189 }
190 let chunk = encode(c.header, &rows).map(Bytes::from);
191 c.header = false;
192 if chunk.is_err() {
193 c.done = true;
194 }
195 Some((chunk, c))
196 })
197}
198
199fn encode(header: bool, rows: &[ExportRow]) -> Result<Vec<u8>, io::Error> {
201 let mut w = csv::Writer::from_writer(Vec::with_capacity(rows.len() * 512 + 512));
202 if header {
203 w.write_record(COLUMNS).map_err(io::Error::other)?;
204 }
205 for r in rows {
206 w.write_record(record(r)).map_err(io::Error::other)?;
207 }
208 w.into_inner().map_err(|e| e.into_error())
209}
210
211fn record(r: &ExportRow) -> [String; 22] {
212 let opt = |n: Option<u32>| n.map(|n| n.to_string()).unwrap_or_default();
213 let len = |s: &Option<String>| s.as_deref().map_or(0, |s| s.chars().count()).to_string();
214 let issues: Vec<&str> = CheckId::ALL
215 .iter()
216 .filter(|c| r.issues.has_check(**c))
217 .map(|c| c.slug())
218 .collect();
219 [
220 text(Some(&r.url)),
221 r.status.to_string(),
222 indexability_label(r.indexability).to_owned(),
223 text(r.content_type.as_deref()),
224 text(r.title.as_deref()),
225 len(&r.title),
226 text(r.meta_description.as_deref()),
227 len(&r.meta_description),
228 text(r.h1.as_deref()),
229 text(r.canonical.as_deref()),
230 text(r.meta_robots.as_deref()),
231 text(r.x_robots_tag.as_deref()),
232 opt(r.word_count),
233 opt(r.depth),
234 r.inlinks.to_string(),
235 r.outlinks_internal.to_string(),
236 r.outlinks_external.to_string(),
237 opt(r.response_ms),
238 r.size_bytes.map(|n| n.to_string()).unwrap_or_default(),
239 if r.in_sitemap { "Yes" } else { "No" }.to_owned(),
240 text(r.redirect_target.as_deref()),
241 issues.join("; "),
242 ]
243}
244
245fn text(value: Option<&str>) -> String {
248 let value = value.unwrap_or_default();
249 if value.starts_with(['=', '+', '-', '@', '\t', '\r']) {
250 format!("'{value}")
251 } else {
252 value.to_owned()
253 }
254}
255
256fn indexability_label(i: Indexability) -> &'static str {
257 match i {
258 Indexability::Indexable => "Indexable",
259 Indexability::Noindex => "Noindex",
260 Indexability::Canonicalised => "Canonicalised",
261 Indexability::Redirected => "Redirected",
262 Indexability::ClientError => "Client error",
263 Indexability::ServerError => "Server error",
264 Indexability::BlockedByRobots => "Blocked by robots.txt",
265 }
266}
267
268#[cfg(test)]
269mod tests {
270 use super::*;
271 use time::macros::date;
272
273 #[test]
274 fn file_names_are_safe() {
275 let day = date!(2026 - 10 - 04);
276 assert_eq!(
277 file_name("example.com", PageFilter::All, day),
278 "example.com-all-2026-10-04.csv"
279 );
280 assert_eq!(
281 file_name("example.com", PageFilter::Check(CheckId::TitleMissing), day),
282 "example.com-check-title_missing-2026-10-04.csv"
283 );
284 assert_eq!(
285 file_name("we\"ird/host", PageFilter::Status4xx, day),
286 "we-ird-host-s4-2026-10-04.csv"
287 );
288 }
289
290 #[test]
291 fn formula_cells_are_neutralised() {
292 assert_eq!(text(Some("=1+1")), "'=1+1");
293 assert_eq!(text(Some("-5")), "'-5");
294 assert_eq!(text(Some("@cmd")), "'@cmd");
295 assert_eq!(text(Some("Plain, \"quoted\"")), "Plain, \"quoted\"");
296 assert_eq!(text(None), "");
297 }
298}