//! 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, Redirect, Response, sse::{Event as SseEvent, Sse}, }, routing::{delete, get, patch, post}, }; use serde::Deserialize; 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, } pub fn router(state: WebState) -> Router { Router::new() .route("/", get(index)) .route("/api/me", get(me).patch(patch_me)) .route("/api/logout", post(logout)) .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", get(all_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/read-all", post(read_all_mine)) .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/popular", get(get_popular)) .route("/api/directory", get(get_directory)) .route("/api/popular/{id}", post(subscribe_popular)) .route("/api/users", get(list_users).post(add_user)) .route("/api/users/{id}", patch(patch_user).delete(remove_user)) .route("/api/logs", get(logs)) .route("/api/events", get(events)) .route("/admin", get(admin_page)) .route("/admin.js", get(admin_js)) .route("/media/{id}", get(media)) .layer(middleware::from_fn_with_state(state.clone(), auth)) // Signing in cannot require being signed in, so these sit outside the auth layer. .route("/login", get(login_page)) .route("/api/login", post(login)) .route("/icon.png", get(icon)) .route("/favicon.ico", get(favicon)) .route("/favicon.png", get(favicon)) .route("/apple-touch-icon.png", get(touch_icon)) .route("/app.js", get(app_js)) .route("/app.css", get(app_css)) .route("/login.js", get(login_js)) .route("/inter.woff2", get(inter)) .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).into_make_service_with_connect_info::(), ) .await .context("serving the web ui") } /// Who is asking, in order of how specific the claim is: a proxy that vouches for a name, /// a session cookie, then the shared token (which is the admin). /// /// Any of them has to survive being put in a cookie: an `