chore: added rss and public api endpoints e0ed2e24
Steve · 2026-05-08 22:19 6 file(s) · +135 −5
Cargo.lock +1 −0
1324 1324
 "serde",
1325 1325
 "serde_json",
1326 1326
 "tokio",
1327 +
 "tower-http",
1327 1328
 "tracing",
1328 1329
 "tracing-subscriber",
1329 1330
 "urlencoding",
apps/easel/.env.example +3 −0
20 20
21 21
# Max retries when picking a non-duplicate artwork
22 22
EASEL_MAX_DEDUP_RETRIES=10
23 +
24 +
# Public base URL (used for absolute links in /feed.xml)
25 +
EASEL_BASE_URL=http://localhost:4242
apps/easel/Cargo.toml +1 −0
25 25
chrono-tz = "0.10"
26 26
urlencoding = "2"
27 27
mime_guess = "2"
28 +
tower-http = { workspace = true, features = ["cors"] }
apps/easel/src/server.rs +127 −4
3 3
use askama::Template;
4 4
use axum::{
5 5
    extract::{Path, State},
6 -
    http::{header, StatusCode},
6 +
    http::{header, Method, StatusCode},
7 7
    response::{Html, IntoResponse, Json, Response},
8 8
    routing::get,
9 9
    Router,
11 11
use chrono::Utc;
12 12
use rust_embed::Embed;
13 13
use serde::Serialize;
14 +
use tower_http::cors::{Any, CorsLayer};
14 15
15 16
use crate::db::{self, DailyArtwork, Db};
16 17
use crate::scheduler;
27 28
    pub exclude_terms: Vec<String>,
28 29
    pub backfill_days: u32,
29 30
    pub max_dedup_retries: u32,
31 +
    pub base_url: String,
30 32
}
31 33
32 34
#[derive(Template)]
310 312
    }
311 313
}
312 314
315 +
fn escape_xml(s: &str) -> String {
316 +
    s.replace('&', "&amp;")
317 +
        .replace('<', "&lt;")
318 +
        .replace('>', "&gt;")
319 +
        .replace('"', "&quot;")
320 +
        .replace('\'', "&apos;")
321 +
}
322 +
323 +
fn entry_published(date: &str) -> String {
324 +
    chrono::NaiveDate::parse_from_str(date, "%Y-%m-%d")
325 +
        .ok()
326 +
        .and_then(|d| d.and_hms_opt(12, 0, 0))
327 +
        .map(|dt| dt.and_utc().to_rfc3339())
328 +
        .unwrap_or_else(|| Utc::now().to_rfc3339())
329 +
}
330 +
331 +
async fn atom_feed_handler(State(state): State<Arc<AppState>>) -> Response {
332 +
    let items = match db::list_daily(&state.db, 100) {
333 +
        Ok(items) => items,
334 +
        Err(e) => {
335 +
            tracing::error!("atom feed query failed: {e}");
336 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
337 +
        }
338 +
    };
339 +
340 +
    let updated = items
341 +
        .first()
342 +
        .map(|i| entry_published(&i.date))
343 +
        .unwrap_or_else(|| Utc::now().to_rfc3339());
344 +
345 +
    let base = state.base_url.trim_end_matches('/');
346 +
    let self_url = format!("{base}/feed.xml");
347 +
348 +
    let mut xml = String::with_capacity(8192);
349 +
    xml.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
350 +
    xml.push_str("<feed xmlns=\"http://www.w3.org/2005/Atom\">\n");
351 +
    xml.push_str("  <title>Easel — Daily Artwork</title>\n");
352 +
    xml.push_str("  <subtitle>A daily painting from the Art Institute of Chicago</subtitle>\n");
353 +
    xml.push_str(&format!(
354 +
        "  <link href=\"{}\" rel=\"self\" type=\"application/atom+xml\" />\n",
355 +
        escape_xml(&self_url)
356 +
    ));
357 +
    xml.push_str(&format!("  <link href=\"{}\" />\n", escape_xml(base)));
358 +
    xml.push_str(&format!("  <id>{}</id>\n", escape_xml(&self_url)));
359 +
    xml.push_str(&format!("  <updated>{updated}</updated>\n"));
360 +
361 +
    for item in &items {
362 +
        let published = entry_published(&item.date);
363 +
        let entry_url = format!("{base}/day/{}", item.date);
364 +
        let author_name = item
365 +
            .artist_title
366 +
            .as_deref()
367 +
            .or(item.artist_display.as_deref())
368 +
            .filter(|s| !s.is_empty())
369 +
            .unwrap_or("Unknown");
370 +
        let summary = item
371 +
            .short_description
372 +
            .as_deref()
373 +
            .or(item.description.as_deref())
374 +
            .unwrap_or("");
375 +
        let image = iiif_url(&item.image_id);
376 +
        let content = format!(
377 +
            "<p><img src=\"{}\" alt=\"{}\" /></p><p>{}</p>",
378 +
            escape_xml(&image),
379 +
            escape_xml(&item.title),
380 +
            escape_xml(summary)
381 +
        );
382 +
383 +
        xml.push_str("  <entry>\n");
384 +
        xml.push_str(&format!(
385 +
            "    <title>{} — {}</title>\n",
386 +
            escape_xml(&item.date),
387 +
            escape_xml(&item.title)
388 +
        ));
389 +
        xml.push_str(&format!(
390 +
            "    <link href=\"{}\" />\n",
391 +
            escape_xml(&entry_url)
392 +
        ));
393 +
        xml.push_str(&format!("    <id>{}</id>\n", escape_xml(&entry_url)));
394 +
        xml.push_str(&format!("    <updated>{published}</updated>\n"));
395 +
        xml.push_str(&format!("    <published>{published}</published>\n"));
396 +
        xml.push_str("    <author>\n");
397 +
        xml.push_str(&format!("      <name>{}</name>\n", escape_xml(author_name)));
398 +
        xml.push_str("    </author>\n");
399 +
        if !summary.is_empty() {
400 +
            xml.push_str(&format!(
401 +
                "    <summary>{}</summary>\n",
402 +
                escape_xml(summary)
403 +
            ));
404 +
        }
405 +
        xml.push_str(&format!(
406 +
            "    <content type=\"html\">{}</content>\n",
407 +
            escape_xml(&content)
408 +
        ));
409 +
        xml.push_str("  </entry>\n");
410 +
    }
411 +
412 +
    xml.push_str("</feed>\n");
413 +
414 +
    (
415 +
        [(header::CONTENT_TYPE, "application/atom+xml; charset=utf-8")],
416 +
        xml,
417 +
    )
418 +
        .into_response()
419 +
}
420 +
313 421
async fn static_handler(Path(path): Path<String>) -> Response {
314 422
    match Static::get(&path) {
315 423
        Some(file) => {
354 462
        .ok()
355 463
        .and_then(|v| v.parse().ok())
356 464
        .unwrap_or(10);
465 +
    let base_url = std::env::var("EASEL_BASE_URL")
466 +
        .unwrap_or_else(|_| "http://localhost:4242".to_string())
467 +
        .trim_end_matches('/')
468 +
        .to_string();
357 469
358 470
    let db = db::init_db(&db_path);
359 471
    let http = crate::aic::build_client();
366 478
        exclude_terms: exclude_terms.clone(),
367 479
        backfill_days,
368 480
        max_dedup_retries,
481 +
        base_url,
369 482
    });
370 483
371 484
    tracing::info!(
380 493
381 494
    tokio::spawn(scheduler::run(state.clone()));
382 495
496 +
    let public_cors = CorsLayer::new()
497 +
        .allow_origin(Any)
498 +
        .allow_methods([Method::GET])
499 +
        .allow_headers(Any);
500 +
501 +
    let api_router = Router::new()
502 +
        .route("/api/today", get(api_today))
503 +
        .route("/api/day/{date}", get(api_day))
504 +
        .route("/api/archive", get(api_archive))
505 +
        .route("/feed.xml", get(atom_feed_handler))
506 +
        .layer(public_cors);
507 +
383 508
    let app = Router::new()
384 509
        .route("/", get(index_handler))
385 510
        .route("/day/{date}", get(day_handler))
386 511
        .route("/archive", get(archive_handler))
387 -
        .route("/api/today", get(api_today))
388 -
        .route("/api/day/{date}", get(api_day))
389 -
        .route("/api/archive", get(api_archive))
390 512
        .route("/static/{*path}", get(static_handler))
513 +
        .merge(api_router)
391 514
        .merge(andromeda_darkmatter_css::router::<Arc<AppState>>())
392 515
        .with_state(state);
393 516
apps/easel/static/styles.css +1 −1
75 75
  overflow: hidden;
76 76
  text-overflow: ellipsis;
77 77
  white-space: nowrap;
78 -
}
78 +
}
apps/easel/templates/base.html +2 −0
17 17
    <link rel="stylesheet" href="/assets/darkmatter.css" />
18 18
    <link rel="stylesheet" href="/static/styles.css" />
19 19
    <meta name="description" content="A daily painting from the Art Institute of Chicago" />
20 +
    <link rel="alternate" type="application/atom+xml" title="Easel — Daily Artwork" href="/feed.xml" />
20 21
  </head>
21 22
  <body>
22 23
    <header class="header">
24 25
      <nav class="links">
25 26
        <a href="/">today</a>
26 27
        <a href="/archive">archive</a>
28 +
        <a href="/feed.xml">rss</a>
27 29
      </nav>
28 30
    </header>
29 31
    <main>