api: auto-detect Greader API path for Miniflux and FreshRSS

Miniflux serves the Greader API at the server root while FreshRSS uses
/api/greader.php. Instead of hardcoding the FreshRSS suffix, try the
URL as-is first (works for Miniflux) and fall back to appending
/api/greader.php (works for FreshRSS). The user just enters the server
URL without needing to know the API path.
This commit is contained in:
Jeena 2026-03-21 11:57:19 +00:00
parent 82aabc080a
commit b49cc69c49
2 changed files with 50 additions and 42 deletions

View file

@ -1,7 +1,9 @@
# FeedTheMonkey
FeedTheMonkey is a desktop client for any server that implements the
[Greader API](https://github.com/theoldreader/api) (such as FreshRSS or Miniflux). It doesn't work as a standalone feed reader —
[Greader API](https://github.com/theoldreader/api), such as
[FreshRSS](https://freshrss.org) or [Miniflux](https://miniflux.app).
Just enter your server URL — the app detects the API path automatically. It doesn't work as a standalone feed reader —
it connects to a server to fetch articles and sync read state.
This is version 3, rewritten in Rust with GTK4 and libadwaita.

View file

@ -42,14 +42,18 @@ struct Summary {
use crate::model::Article;
fn normalize_base_url(server_url: &str) -> String {
/// Return the candidate base URLs to try in order.
/// Miniflux serves the Greader API at the server root;
/// FreshRSS serves it at /api/greader.php.
fn candidate_base_urls(server_url: &str) -> Vec<String> {
let base = server_url.trim_end_matches('/');
// If the user entered just a host (or host/path) without the FreshRSS
// API suffix, append it automatically.
if base.ends_with("/api/greader.php") {
base.to_string()
vec![base.to_string()]
} else {
format!("{base}/api/greader.php")
vec![
base.to_string(),
format!("{base}/api/greader.php"),
]
}
}
@ -59,47 +63,49 @@ impl Api {
username: &str,
password: &str,
) -> Result<Self, String> {
let base = normalize_base_url(server_url);
let client = Client::new();
let candidates = candidate_base_urls(server_url);
let mut last_err = String::new();
for base in candidates {
let url = format!("{base}/accounts/ClientLogin");
let resp = client
let resp = match client
.post(&url)
.form(&[("Email", username), ("Passwd", password)])
.send()
.await
.map_err(|e| e.to_string())?;
{
Ok(r) => r,
Err(e) => { last_err = e.to_string(); continue; }
};
let status = resp.status();
let body = resp.text().await.map_err(|e| e.to_string())?;
if !status.is_success() {
let msg = human_error(&body, status.as_u16());
return Err(format!("Login failed ({}): {}", status.as_u16(), msg));
last_err = format!("Login failed ({}): {}", status.as_u16(), human_error(&body, status.as_u16()));
continue;
}
let auth_token = body
.lines()
.find_map(|l| l.strip_prefix("Auth="))
.ok_or_else(|| {
// The server returned 200 but not the expected API response —
// most likely the URL points to a web page, not a FreshRSS API.
if looks_like_html(&body) {
let auth_token = match body.lines().find_map(|l| l.strip_prefix("Auth=")) {
Some(t) => t.to_string(),
None => {
last_err = if looks_like_html(&body) {
format!(
"The server at {} does not appear to be a FreshRSS \
Google Reader API endpoint. Check your server URL.",
base
"The server at {base} does not appear to be a \
Greader API endpoint. Check your server URL."
)
} else {
format!("Unexpected response from server: {}", body.trim())
};
continue;
}
})?
.to_string();
};
Ok(Self {
client,
server_url: base,
auth_token,
})
return Ok(Self { client, server_url: base, auth_token });
}
Err(last_err)
}
pub async fn fetch_write_token(&self) -> Result<String, String> {