//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly. use anyhow::{Context, Result}; use axum::{ Json, Router, extract::{Path, Query, Request, State}, http::{StatusCode, header}, middleware::{self, Next}, response::{ Html, IntoResponse, Response, sse::{Event as SseEvent, Sse}, }, routing::{delete, get, patch, post}, }; use futures_util::StreamExt; use serde::Deserialize; use tokio_stream::wrappers::BroadcastStream; use tower::ServiceExt; use tower_http::services::ServeFile; use serde::Serialize; use std::path::PathBuf; use std::sync::Arc; use tokio::sync::{broadcast, mpsc}; use crate::Ctx; use crate::ipc::{Command, Event}; const COOKIE: &str = "ipx_token"; #[derive(Clone)] pub struct WebState { pub ctx: Arc, pub config_path: PathBuf, pub cmds: mpsc::Sender, pub events: broadcast::Sender, } /// A 32-hex-character shared secret, generated when config.toml has none. /// /// ponytail: /dev/urandom rather than a CSPRNG crate -- 16 bytes, once, on a Unix-only /// binary. Falls back to the clock only if urandom is somehow unreadable, which would be a /// weak token, so that case is logged loudly. pub fn generate_token() -> String { use std::io::Read; let mut bytes = [0u8; 16]; match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)) { Ok(()) => {} Err(e) => { tracing::error!(error = %e, "could not read /dev/urandom; token is NOT secure"); let n = std::time::SystemTime::now() .duration_since(std::time::UNIX_EPOCH) .map(|d| d.as_nanos() as u64) .unwrap_or(0); bytes[..8].copy_from_slice(&n.to_le_bytes()); } } bytes.iter().map(|b| format!("{b:02x}")).collect() } pub fn router(state: WebState) -> Router { Router::new() .route("/", get(index)) .route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}/entries", get(entries)) .route("/api/entries/{feed_id}/{guid}/flags", post(set_flags)) .route("/api/entries/{feed_id}/{guid}/position", post(set_position)) .route("/api/feeds/{id}/read-all", post(read_all)) .route("/api/feeds/{id}/download-latest", post(download_latest)) .route("/api/enclosures/{id}/download", post(download_now)) .route("/api/enclosures/{id}", delete(delete_file)) .route("/api/fetch", post(fetch_now)) .route("/api/opml", get(export_opml).post(import_opml)) .route("/api/settings", get(get_settings).patch(patch_settings)) .route("/api/logs", get(logs)) .route("/api/events", get(events)) .route("/media/{id}", get(media)) .layer(middleware::from_fn_with_state(state.clone(), auth)) .layer(middleware::from_fn(access_log)) .with_state(state) } pub async fn serve(state: WebState, bind: &str) -> Result<()> { let listener = tokio::net::TcpListener::bind(bind) .await .with_context(|| format!("binding {bind}"))?; tracing::info!(bind, "web ui listening"); axum::serve(listener, router(state)) .await .context("serving the web ui") } /// Token in `?token=` (which then sets a cookie) or in the cookie itself. /// /// It has to be a cookie rather than a header: an `