follow_move, which rewrites a feed's address in the catalogue after a permanent redirect, ran in no test; the fetch only said where a feed went. It now: the feed keeps its id, so its subscribers and their read state, takes the new address in memory and in the database, and is left as it was when another feed already has that address, or when it is an OPML's, whose list would only put the old one back. WordPress.com's browser check is answered from its API's posts, and a Patreon creator's shows are asked of Patreon by the campaign its feed names, each show the creator feed with its own show and credentials; an unknown one is an error. Both against a stand-in: the two APIs' addresses go through api_base, which a test points at a server of its own; production's are unchanged. ipx add names a feed from its title, keeps its folder and keywords, refuses it a second time, and asked again to list it or with a category changes only those; ipx rm takes it out of the catalogue and the database. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
93 lines
4.0 KiB
Rust
93 lines
4.0 KiB
Rust
//! Feed fetching and parsing, shared by ipx and the directory (#202). Replaces
|
|
//! FeedData.__getFeed / __getEntries.
|
|
|
|
mod artwork;
|
|
mod category;
|
|
mod discover;
|
|
mod failure;
|
|
mod fetch;
|
|
pub mod guard;
|
|
mod jev;
|
|
mod outlines;
|
|
mod parse;
|
|
mod patreon;
|
|
mod privacy;
|
|
mod wordpress;
|
|
|
|
pub use artwork::{is_image, prefer_https, site_icon};
|
|
pub use category::{categories, category_path};
|
|
pub use discover::{expand_input, find_feed};
|
|
pub use failure::{Failure, explain_failure, failure_kind, is_temporary_lookup_failure};
|
|
pub use fetch::{FEED_SILENCE, FEED_TIMEOUT, FETCHED_BYTES, FETCHES, Fetched, fetch, is_youtube_feed};
|
|
pub use jev::{JEV_URL, classify};
|
|
pub use outlines::{collect_outlines, is_opml, opml_title, parse_opml, read_opml};
|
|
pub use parse::{Enclosure, Entry, ParsedFeed, markdown_html, parse, same_file_key};
|
|
pub use patreon::{is_patreon_creator, patreon_shows, same_feed};
|
|
pub use privacy::{redact, redact_text, url_looks_private};
|
|
|
|
/// Where another service's API is (WordPress.com's, Patreon's): theirs, or in a test the stand-in
|
|
/// `test_api` serves.
|
|
pub(crate) fn api_base(real: &'static str) -> String {
|
|
#[cfg(test)]
|
|
{
|
|
let _ = real;
|
|
test_api::base()
|
|
}
|
|
#[cfg(not(test))]
|
|
real.to_owned()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub(crate) mod test_api {
|
|
use std::io::{BufRead, BufReader, Write};
|
|
|
|
/// A stand-in for WordPress.com's and Patreon's APIs, and for the feeds that send ipx to them,
|
|
/// on a thread of its own so it outlives any one test's runtime. By path:
|
|
/// `/challenged` answers as WordPress.com's browser check does, `/wp/v2/sites/<host>/posts`
|
|
/// with one post, `/creator` as a Patreon creator feed of campaign 12345, and
|
|
/// `/api/campaigns/12345` with its two shows. Anything else is a 404.
|
|
pub(crate) fn base() -> String {
|
|
static AT: std::sync::OnceLock<String> = std::sync::OnceLock::new();
|
|
AT.get_or_init(|| {
|
|
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
|
let at = format!("http://{}", l.local_addr().unwrap());
|
|
std::thread::spawn(move || {
|
|
for mut sock in l.incoming().flatten() {
|
|
let mut line = String::new();
|
|
let _ = BufReader::new(&sock).read_line(&mut line);
|
|
let path = line.split_whitespace().nth(1).unwrap_or("/").to_owned();
|
|
let (status, body) = answer(&path);
|
|
let _ = write!(
|
|
sock,
|
|
"HTTP/1.1 {status}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{body}",
|
|
body.len()
|
|
);
|
|
}
|
|
});
|
|
at
|
|
})
|
|
.clone()
|
|
}
|
|
|
|
fn answer(path: &str) -> (&'static str, String) {
|
|
if path.starts_with("/challenged") {
|
|
("403 Forbidden", r#"<html><script src="/__challenge/check.js"></script></html>"#.into())
|
|
} else if path.starts_with("/wp/v2/sites/127.0.0.1/posts") {
|
|
let post = r#"[{"date_gmt":"2026-10-09T13:34:00","guid":{"rendered":"https://blog.test/?p=1"},
|
|
"link":"https://blog.test/hello/","title":{"rendered":"Hello"},"content":{"rendered":"<p>Hi</p>"}}]"#;
|
|
("200 OK", post.into())
|
|
} else if path.starts_with("/creator") {
|
|
let feed = r#"<?xml version="1.0"?><rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom"><channel>
|
|
<atom:link href="https://www.patreon.com/rss/12345?auth=x" rel="self"/><title>Creator</title></channel></rss>"#;
|
|
("200 OK", feed.into())
|
|
} else if path.starts_with("/api/campaigns/12345?") {
|
|
let shows = r#"{"data":{"attributes":{"name":"Glass Cannon"},
|
|
"relationships":{"shows":{"data":[{"id":"7","type":"collection"},{"id":"8","type":"collection"}]}}},
|
|
"included":[{"id":"7","type":"collection","attributes":{"title":" Main Show "}}]}"#;
|
|
("200 OK", shows.into())
|
|
} else {
|
|
("404 Not Found", String::new())
|
|
}
|
|
}
|
|
}
|