FeedTheMonkey/src/api.rs
Jeena 141f9ee32d fix: show human-readable login errors instead of raw HTML
When the server returns an HTML response (wrong URL, redirect to a
login page), the error dialog previously showed the full HTML body.
Now detect HTML responses and show a short actionable message:
- 404 with HTML: 'API endpoint not found. Check your server URL.'
- 401/403 with HTML: 'Wrong username or password.'
- 200 with HTML (no Auth= token): explain the endpoint is not FreshRSS
- Non-HTML bodies are shown as-is (they are already readable)
2026-03-20 12:21:01 +00:00

251 lines
7.5 KiB
Rust

use reqwest::Client;
use serde::Deserialize;
#[derive(Debug, Clone)]
pub struct Api {
client: Client,
pub server_url: String,
pub auth_token: String,
}
#[derive(Debug, Deserialize)]
struct StreamContents {
items: Vec<Item>,
}
#[derive(Debug, Deserialize)]
struct Item {
id: String,
title: Option<String>,
origin: Option<Origin>,
canonical: Option<Vec<Link>>,
published: Option<i64>,
summary: Option<Summary>,
author: Option<String>,
categories: Option<Vec<String>>,
}
#[derive(Debug, Deserialize)]
struct Origin {
title: Option<String>,
}
#[derive(Debug, Deserialize)]
struct Link {
href: String,
}
#[derive(Debug, Deserialize)]
struct Summary {
content: Option<String>,
}
use crate::model::Article;
fn normalize_base_url(server_url: &str) -> 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()
} else {
format!("{base}/api/greader.php")
}
}
impl Api {
pub async fn login(
server_url: &str,
username: &str,
password: &str,
) -> Result<Self, String> {
let base = normalize_base_url(server_url);
let client = Client::new();
let url = format!("{base}/accounts/ClientLogin");
let resp = client
.post(&url)
.form(&[("Email", username), ("Passwd", password)])
.send()
.await
.map_err(|e| e.to_string())?;
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));
}
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) {
format!(
"The server at {} does not appear to be a FreshRSS \
Google Reader API endpoint. Check your server URL.",
base
)
} else {
format!("Unexpected response from server: {}", body.trim())
}
})?
.to_string();
Ok(Self {
client,
server_url: base,
auth_token,
})
}
pub async fn fetch_write_token(&self) -> Result<String, String> {
let url = format!("{}/reader/api/0/token", self.server_url);
let resp = self
.client
.get(&url)
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch write token: {}", resp.status()));
}
resp.text().await.map_err(|e| e.to_string()).map(|s| s.trim().to_string())
}
pub async fn fetch_unread(&self) -> Result<Vec<Article>, String> {
let url = format!(
"{}/reader/api/0/stream/contents/reading-list\
?xt=user/-/state/com.google/read&n=200&output=json",
self.server_url
);
let resp = self
.client
.get(&url)
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
.send()
.await
.map_err(|e| e.to_string())?;
if !resp.status().is_success() {
return Err(format!("Failed to fetch articles: {}", resp.status()));
}
let stream: StreamContents = resp.json().await.map_err(|e| e.to_string())?;
Ok(stream.items.into_iter().map(|item| {
let unread = !item.categories.as_deref().unwrap_or_default()
.iter()
.any(|c| c.contains("user/-/state/com.google/read"));
let content = item.summary
.as_ref()
.and_then(|s| s.content.clone())
.unwrap_or_default();
let excerpt = plain_text_excerpt(&content, 150);
Article {
id: item.id,
title: item.title.unwrap_or_default(),
feed_title: item.origin.as_ref().and_then(|o| o.title.clone()).unwrap_or_default(),
author: item.author.unwrap_or_default(),
link: item.canonical.as_ref()
.and_then(|v| v.first())
.map(|l| l.href.clone())
.unwrap_or_default(),
published: item.published.unwrap_or(0),
content,
excerpt,
unread,
}
}).collect())
}
pub async fn mark_read(
&self,
write_token: &str,
item_id: &str,
) -> Result<(), String> {
self.edit_tag(write_token, item_id, "a", "user/-/state/com.google/read").await
}
pub async fn mark_unread(
&self,
write_token: &str,
item_id: &str,
) -> Result<(), String> {
self.edit_tag(write_token, item_id, "r", "user/-/state/com.google/read").await
}
async fn edit_tag(
&self,
write_token: &str,
item_id: &str,
action_key: &str,
state: &str,
) -> Result<(), String> {
let url = format!("{}/reader/api/0/edit-tag", self.server_url);
let resp = self
.client
.post(&url)
.header("Authorization", format!("GoogleLogin auth={}", self.auth_token))
.form(&[("i", item_id), (action_key, state), ("T", write_token)])
.send()
.await
.map_err(|e| e.to_string())?;
if resp.status() == reqwest::StatusCode::UNAUTHORIZED {
return Err("UNAUTHORIZED".to_string());
}
if !resp.status().is_success() {
return Err(format!("edit-tag failed: {}", resp.status()));
}
Ok(())
}
}
fn looks_like_html(body: &str) -> bool {
let trimmed = body.trim_start();
trimmed.starts_with("<!") || trimmed.to_ascii_lowercase().starts_with("<html")
}
fn human_error(body: &str, status: u16) -> String {
if looks_like_html(body) {
match status {
401 | 403 => "Wrong username or password.".to_string(),
404 => "API endpoint not found. Check your server URL.".to_string(),
_ => format!("Server returned HTTP {status}. Check your server URL."),
}
} else {
let trimmed = body.trim();
if trimmed.is_empty() {
format!("Server returned HTTP {status} with no message.")
} else {
trimmed.to_string()
}
}
}
fn plain_text_excerpt(html: &str, max_chars: usize) -> String {
// Very simple HTML stripper — remove tags, collapse whitespace
let mut out = String::with_capacity(html.len());
let mut in_tag = false;
for ch in html.chars() {
match ch {
'<' => in_tag = true,
'>' => in_tag = false,
c if !in_tag => out.push(c),
_ => {}
}
}
let collapsed: String = out.split_whitespace().collect::<Vec<_>>().join(" ");
if collapsed.chars().count() <= max_chars {
collapsed
} else {
collapsed.chars().take(max_chars).collect::<String>() + ""
}
}