Skip to main content

codoseo_web/routes/
export.rs

1//! `/s/{site}/export.csv?filter=&q=`: the latest finished crawl's pages as CSV, with the
2//! explorer's filter and search applied.
3//!
4//! The body streams: each step reads one keyset-paged batch of rows, encodes it with
5//! `csv::Writer` and sends it as one chunk, so a 50,000-page export never holds more than a
6//! batch in memory.
7
8use 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
34/// Rows per database read and per body chunk.
35const 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
121/// `example.com-check-title_missing-2026-10-04.csv`: the domain, the filter's URL form with
122/// `:` made file-safe, and the day the crawl finished.
123fn 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
145/// Where the export has got to.
146struct Cursor {
147    pool: PgPool,
148    crawl_id: Uuid,
149    filter: PageFilter,
150    q: Option<String>,
151    /// The last `pages.id` sent.
152    after: i64,
153    /// The header row still has to go out (with the first batch).
154    header: bool,
155    done: bool,
156}
157
158/// The CSV body, one chunk per batch. A database error partway is logged and ends the body
159/// with an error: the status and headers have already gone out, so the client sees an
160/// interrupted download rather than a file that looks complete.
161fn 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
199/// One chunk of CSV: the header row first if asked, then a record per row.
200fn 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
245/// A crawled text cell. Text a spreadsheet would run as a formula (starting with `=`, `+`,
246/// `-`, `@`, a tab or a CR) gets a leading `'`, since crawled pages are untrusted input.
247fn 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}