| 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('&', "&") |
|
317 |
+ |
.replace('<', "<") |
|
318 |
+ |
.replace('>', ">") |
|
319 |
+ |
.replace('"', """) |
|
320 |
+ |
.replace('\'', "'") |
|
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 |
|
|