Make adding RSS sources painless. Instead of requiring the exact feed URL, a user can paste any page — a blog homepage, a YouTube channel, a subreddit, a Mastodon profile — and the server resolves the concrete feed it advertises. Plus OPML bulk-import to migrate a reader export or a YouTube subscriptions list in one shot. No new SourceKind, no migration; this enriches the existing worker-polled RSS rail. New crate `newsfeed-fetch` — the one place that does outbound feed HTTP: - probe(url): normalise (+ Reddit /.rss rewrite) -> fetch -> if it parses as a feed, done; else scan the HTML <head> for <link rel=alternate type=rss/atom>, resolve the relative href, and validate. Covers YouTube/Mastodon/Substack/ WordPress/blogs, which all advertise their feed this way. - parse_opml(): recurses nested outline folders. - fetch_entries()/entry mapping moved here from the worker so both the API (discovery) and worker (polling) share a single HTTP+feed-rs path. - core: add the FeedProbe port (kept I/O-free; adapter lives in newsfeed-fetch). - api: AppState carries Arc<dyn FeedProbe>; POST /v1/sources resolves a pasted homepage to its feed; add POST /v1/sources/discover (preview) and /v1/sources/import (OPML, deduped by URL, per-feed failures collected). - worker: delegate to newsfeed_fetch::fetch_entries; drop the sourcing/ module. - web: Sources page gains paste-URL + "Find feed" preview, OPML file import with a summary, and a "polled Nm ago" hint per source; new client methods and regenerated ts-rs bindings. Verified end-to-end locally: homepage URL -> discovered feed.xml + title; create stores the resolved URL; OPML import added 1/skipped 1 dup; worker polled and both items landed in the feed. fmt/clippy/test + web build/lint green. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016fKZzDpvjiJ9eYbPGgJvUP
77 lines
2.6 KiB
Rust
77 lines
2.6 KiB
Rust
//! Router assembly. Public API is versioned under `/v1` from day one; `/health` is
|
|
//! unversioned for infrastructure probes.
|
|
|
|
mod auth_routes;
|
|
mod feed;
|
|
mod health;
|
|
mod ingest;
|
|
mod interests;
|
|
mod sources;
|
|
mod tokens;
|
|
|
|
use axum::Router;
|
|
use axum::http::{Method, header};
|
|
use axum::routing::{delete, get, post};
|
|
use tower_http::compression::CompressionLayer;
|
|
use tower_http::cors::CorsLayer;
|
|
use tower_http::trace::TraceLayer;
|
|
|
|
use crate::state::AppState;
|
|
|
|
/// Build the full application router.
|
|
pub fn router(state: AppState) -> Router {
|
|
let cors = build_cors(&state);
|
|
|
|
let v1 = Router::new()
|
|
// auth
|
|
.route("/auth/register", post(auth_routes::register))
|
|
.route("/auth/login", post(auth_routes::login))
|
|
.route("/auth/logout", post(auth_routes::logout))
|
|
.route("/auth/me", get(auth_routes::me))
|
|
// feed
|
|
.route("/feed", get(feed::get_feed))
|
|
.route("/feed/signals", post(feed::post_signal))
|
|
// sources
|
|
.route("/sources", get(sources::list).post(sources::create))
|
|
.route("/sources/discover", post(sources::discover))
|
|
.route("/sources/import", post(sources::import))
|
|
.route("/sources/{id}", delete(sources::remove))
|
|
// interests
|
|
.route("/interests", get(interests::list).put(interests::upsert))
|
|
.route("/interests/{id}", delete(interests::remove))
|
|
// api tokens
|
|
.route("/tokens", get(tokens::list).post(tokens::create))
|
|
.route("/tokens/{id}", delete(tokens::revoke))
|
|
// ingest (bearer-token authenticated)
|
|
.route("/ingest/candidates", post(ingest::submit));
|
|
|
|
Router::new()
|
|
.route("/health", get(health::health))
|
|
.nest("/v1", v1)
|
|
.layer(TraceLayer::new_for_http())
|
|
.layer(CompressionLayer::new())
|
|
.layer(cors)
|
|
.with_state(state)
|
|
}
|
|
|
|
/// CORS is only needed in development, where the Vite dev server is a different origin
|
|
/// from the API. In production nginx serves the SPA and proxies the API under one origin,
|
|
/// so `cors_origins` is empty and no cross-origin headers are emitted.
|
|
fn build_cors(state: &AppState) -> CorsLayer {
|
|
if state.config.cors_origins.is_empty() {
|
|
return CorsLayer::new();
|
|
}
|
|
let origins = state
|
|
.config
|
|
.cors_origins
|
|
.iter()
|
|
.filter_map(|o| o.parse().ok())
|
|
.collect::<Vec<_>>();
|
|
// Credentialed CORS forbids wildcard method/header lists, so enumerate them.
|
|
CorsLayer::new()
|
|
.allow_origin(origins)
|
|
.allow_methods([Method::GET, Method::POST, Method::PUT, Method::DELETE])
|
|
.allow_headers([header::CONTENT_TYPE, header::AUTHORIZATION])
|
|
.allow_credentials(true)
|
|
}
|