chore: added freshrss helper f3aa4519
Steve · 2026-04-16 09:11 3 file(s) · +192 −199
apps/feeds/src/feeds.rs +170 −168
1 1
use crate::models::{FeedItem, FreshRSSResponse, SubscriptionList};
2 2
use std::time::Duration;
3 3
4 +
#[derive(Clone)]
5 +
pub struct FreshRSSConfig {
6 +
    pub url: String,
7 +
    pub username: String,
8 +
    pub password: String,
9 +
}
10 +
11 +
impl FreshRSSConfig {
12 +
    pub fn from_env() -> Option<Self> {
13 +
        Some(Self {
14 +
            url: std::env::var("FRESHRSS_URL").ok()?,
15 +
            username: std::env::var("FRESHRSS_USERNAME").ok()?,
16 +
            password: std::env::var("FRESHRSS_PASSWORD").ok()?,
17 +
        })
18 +
    }
19 +
}
20 +
21 +
struct FreshRSSClient {
22 +
    client: reqwest::Client,
23 +
    base_url: String,
24 +
    token: String,
25 +
}
26 +
27 +
impl FreshRSSClient {
28 +
    async fn new(config: &FreshRSSConfig) -> Result<Self, String> {
29 +
        let client = build_client();
30 +
        let auth_url = format!(
31 +
            "{}/api/greader.php/accounts/ClientLogin?Email={}&Passwd={}",
32 +
            config.url, config.username, config.password
33 +
        );
34 +
35 +
        let text = client
36 +
            .get(&auth_url)
37 +
            .send()
38 +
            .await
39 +
            .map_err(|e| format!("Auth request failed: {e}"))?
40 +
            .text()
41 +
            .await
42 +
            .map_err(|e| format!("Failed to read auth response: {e}"))?;
43 +
44 +
        let token = text
45 +
            .lines()
46 +
            .find_map(|line| line.strip_prefix("Auth="))
47 +
            .map(|t| t.trim().to_string())
48 +
            .ok_or_else(|| "Authentication failed: no Auth token found".to_string())?;
49 +
50 +
        Ok(Self {
51 +
            client,
52 +
            base_url: config.url.clone(),
53 +
            token,
54 +
        })
55 +
    }
56 +
57 +
    fn api_url(&self, path: &str) -> String {
58 +
        format!("{}/api/greader.php/{}", self.base_url, path)
59 +
    }
60 +
61 +
    fn auth_get(&self, path: &str) -> reqwest::RequestBuilder {
62 +
        self.client
63 +
            .get(self.api_url(path))
64 +
            .header("Authorization", format!("GoogleLogin auth={}", self.token))
65 +
    }
66 +
67 +
    fn auth_post(&self, path: &str) -> reqwest::RequestBuilder {
68 +
        self.client
69 +
            .post(self.api_url(path))
70 +
            .header("Authorization", format!("GoogleLogin auth={}", self.token))
71 +
    }
72 +
73 +
    async fn fetch_items(&self) -> Result<Vec<FeedItem>, String> {
74 +
        let data: FreshRSSResponse = self
75 +
            .auth_get("reader/api/0/stream/contents/reading-list?n=60&r=d")
76 +
            .send()
77 +
            .await
78 +
            .map_err(|e| format!("Failed to fetch reading list: {e}"))?
79 +
            .json()
80 +
            .await
81 +
            .map_err(|e| format!("Failed to parse FreshRSS response: {e}"))?;
82 +
83 +
        let mut items: Vec<FeedItem> = data
84 +
            .items
85 +
            .iter()
86 +
            .map(|item| {
87 +
                let link = item
88 +
                    .canonical
89 +
                    .as_ref()
90 +
                    .and_then(|c| c.first())
91 +
                    .map(|l| l.href.clone())
92 +
                    .unwrap_or_default();
93 +
94 +
                FeedItem {
95 +
                    id: item.id.clone(),
96 +
                    title: item.title.clone(),
97 +
                    published: item.published,
98 +
                    author: item.origin.title.clone(),
99 +
                    link,
100 +
                    origin: item.origin.title.clone(),
101 +
                }
102 +
            })
103 +
            .collect();
104 +
105 +
        items.sort_by(|a, b| b.published.cmp(&a.published));
106 +
        Ok(items)
107 +
    }
108 +
109 +
    async fn fetch_subscriptions(&self) -> Result<SubscriptionList, String> {
110 +
        let response = self
111 +
            .auth_get("reader/api/0/subscription/list?output=json")
112 +
            .send()
113 +
            .await
114 +
            .map_err(|e| format!("Failed to fetch subscriptions: {e}"))?;
115 +
116 +
        if !response.status().is_success() {
117 +
            return Err(format!("FreshRSS API error: {}", response.status()));
118 +
        }
119 +
120 +
        response
121 +
            .json()
122 +
            .await
123 +
            .map_err(|e| format!("Failed to parse subscription list: {e}"))
124 +
    }
125 +
126 +
    async fn add_subscription(&self, feed_url: &str) -> Result<String, String> {
127 +
        let response = self
128 +
            .auth_post("reader/api/0/subscription/quickadd")
129 +
            .form(&[("quickadd", feed_url)])
130 +
            .send()
131 +
            .await
132 +
            .map_err(|e| format!("Failed to add subscription: {e}"))?;
133 +
134 +
        if !response.status().is_success() {
135 +
            let status = response.status();
136 +
            let body = response.text().await.unwrap_or_default();
137 +
            return Err(format!("FreshRSS API error ({}): {}", status, body));
138 +
        }
139 +
140 +
        let stream_id = format!("feed/{feed_url}");
141 +
        let response = self
142 +
            .auth_post("reader/api/0/subscription/edit")
143 +
            .form(&[
144 +
                ("ac", "edit"),
145 +
                ("s", &stream_id),
146 +
                ("a", "user/-/label/Feeds"),
147 +
            ])
148 +
            .send()
149 +
            .await
150 +
            .map_err(|e| format!("Feed added but failed to set category: {e}"))?;
151 +
152 +
        if !response.status().is_success() {
153 +
            let status = response.status();
154 +
            let body = response.text().await.unwrap_or_default();
155 +
            return Err(format!(
156 +
                "Feed added but failed to set category ({}): {}",
157 +
                status, body
158 +
            ));
159 +
        }
160 +
161 +
        Ok(format!("Successfully added feed: {feed_url}"))
162 +
    }
163 +
}
164 +
4 165
fn build_client() -> reqwest::Client {
5 166
    reqwest::Client::builder()
6 167
        .timeout(Duration::from_secs(5))
142 303
    urls
143 304
}
144 305
145 -
async fn freshrss_auth(
146 -
    client: &reqwest::Client,
147 -
    freshrss_url: &str,
148 -
    username: &str,
149 -
    password: &str,
150 -
) -> Result<String, String> {
151 -
    let auth_url = format!(
152 -
        "{}/api/greader.php/accounts/ClientLogin?Email={}&Passwd={}",
153 -
        freshrss_url, username, password
154 -
    );
155 -
156 -
    let response = client
157 -
        .get(&auth_url)
158 -
        .send()
159 -
        .await
160 -
        .map_err(|e| format!("Auth request failed: {e}"))?;
161 -
162 -
    let text = response
163 -
        .text()
164 -
        .await
165 -
        .map_err(|e| format!("Failed to read auth response: {e}"))?;
166 -
167 -
    for line in text.lines() {
168 -
        if let Some(token) = line.strip_prefix("Auth=") {
169 -
            return Ok(token.trim().to_string());
170 -
        }
171 -
    }
172 -
173 -
    Err("Authentication failed: no Auth token found".to_string())
174 -
}
175 -
176 -
pub async fn fetch_freshrss_items(
177 -
    freshrss_url: &str,
178 -
    username: &str,
179 -
    password: &str,
180 -
) -> Result<Vec<FeedItem>, String> {
181 -
    let client = build_client();
182 -
    let token = freshrss_auth(&client, freshrss_url, username, password).await?;
183 -
184 -
    let url = format!(
185 -
        "{}/api/greader.php/reader/api/0/stream/contents/reading-list?n=60&r=d",
186 -
        freshrss_url
187 -
    );
188 -
189 -
    let response = client
190 -
        .get(&url)
191 -
        .header("Authorization", format!("GoogleLogin auth={token}"))
192 -
        .send()
193 -
        .await
194 -
        .map_err(|e| format!("Failed to fetch reading list: {e}"))?;
195 -
196 -
    let data: FreshRSSResponse = response
197 -
        .json()
198 -
        .await
199 -
        .map_err(|e| format!("Failed to parse FreshRSS response: {e}"))?;
200 -
201 -
    let mut items: Vec<FeedItem> = data
202 -
        .items
203 -
        .iter()
204 -
        .map(|item| {
205 -
            let link = item
206 -
                .canonical
207 -
                .as_ref()
208 -
                .and_then(|c| c.first())
209 -
                .map(|l| l.href.clone())
210 -
                .unwrap_or_default();
211 -
212 -
            FeedItem {
213 -
                id: item.id.clone(),
214 -
                title: item.title.clone(),
215 -
                published: item.published,
216 -
                author: item.origin.title.clone(),
217 -
                link,
218 -
                origin: item.origin.title.clone(),
219 -
            }
220 -
        })
221 -
        .collect();
222 -
223 -
    items.sort_by(|a, b| b.published.cmp(&a.published));
224 -
    Ok(items)
306 +
pub async fn fetch_freshrss_items(config: &FreshRSSConfig) -> Result<Vec<FeedItem>, String> {
307 +
    FreshRSSClient::new(config).await?.fetch_items().await
225 308
}
226 309
227 310
pub async fn fetch_freshrss_subscriptions(
228 -
    freshrss_url: &str,
229 -
    username: &str,
230 -
    password: &str,
311 +
    config: &FreshRSSConfig,
231 312
) -> Result<SubscriptionList, String> {
232 -
    let client = build_client();
233 -
    let token = freshrss_auth(&client, freshrss_url, username, password).await?;
234 -
235 -
    let url = format!(
236 -
        "{}/api/greader.php/reader/api/0/subscription/list?output=json",
237 -
        freshrss_url
238 -
    );
239 -
240 -
    let response = client
241 -
        .get(&url)
242 -
        .header("Authorization", format!("GoogleLogin auth={token}"))
243 -
        .send()
244 -
        .await
245 -
        .map_err(|e| format!("Failed to fetch subscriptions: {e}"))?;
246 -
247 -
    if !response.status().is_success() {
248 -
        return Err(format!("FreshRSS API error: {}", response.status()));
249 -
    }
250 -
251 -
    let data: SubscriptionList = response
252 -
        .json()
253 -
        .await
254 -
        .map_err(|e| format!("Failed to parse subscription list: {e}"))?;
255 -
256 -
    Ok(data)
313 +
    FreshRSSClient::new(config).await?.fetch_subscriptions().await
257 314
}
258 315
259 316
pub async fn add_freshrss_subscription(
260 -
    freshrss_url: &str,
261 -
    username: &str,
262 -
    password: &str,
317 +
    config: &FreshRSSConfig,
263 318
    feed_url: &str,
264 319
) -> Result<String, String> {
265 -
    let client = build_client();
266 -
    let token = freshrss_auth(&client, freshrss_url, username, password).await?;
267 -
268 -
    let url = format!(
269 -
        "{}/api/greader.php/reader/api/0/subscription/quickadd",
270 -
        freshrss_url
271 -
    );
272 -
273 -
    let response = client
274 -
        .post(&url)
275 -
        .header("Authorization", format!("GoogleLogin auth={token}"))
276 -
        .form(&[("quickadd", feed_url)])
277 -
        .send()
278 -
        .await
279 -
        .map_err(|e| format!("Failed to add subscription: {e}"))?;
280 -
281 -
    if !response.status().is_success() {
282 -
        let status = response.status();
283 -
        let body = response.text().await.unwrap_or_default();
284 -
        return Err(format!("FreshRSS API error ({}): {}", status, body));
285 -
    }
286 -
287 -
    // Assign the "Feeds" category via subscription/edit
288 -
    let edit_url = format!(
289 -
        "{}/api/greader.php/reader/api/0/subscription/edit",
290 -
        freshrss_url
291 -
    );
292 -
293 -
    let stream_id = format!("feed/{feed_url}");
294 -
    let response = client
295 -
        .post(&edit_url)
296 -
        .header("Authorization", format!("GoogleLogin auth={token}"))
297 -
        .form(&[
298 -
            ("ac", "edit"),
299 -
            ("s", &stream_id),
300 -
            ("a", "user/-/label/Feeds"),
301 -
        ])
302 -
        .send()
303 -
        .await
304 -
        .map_err(|e| format!("Feed added but failed to set category: {e}"))?;
305 -
306 -
    if !response.status().is_success() {
307 -
        let status = response.status();
308 -
        let body = response.text().await.unwrap_or_default();
309 -
        return Err(format!(
310 -
            "Feed added but failed to set category ({}): {}",
311 -
            status, body
312 -
        ));
313 -
    }
314 -
315 -
    Ok(format!("Successfully added feed: {feed_url}"))
320 +
    FreshRSSClient::new(config).await?.add_subscription(feed_url).await
316 321
}
317 322
318 323
#[cfg(test)]
373 378
374 379
pub async fn get_feed_items(
375 380
    url_query: Option<&str>,
381 +
    freshrss_config: Option<&FreshRSSConfig>,
376 382
) -> Result<(Vec<FeedItem>, Option<Vec<String>>), String> {
377 -
    // Priority 1: URL query parameter
378 383
    if let Some(query) = url_query {
379 384
        let urls: Vec<String> = query
380 385
            .split(',')
388 393
        }
389 394
    }
390 395
391 -
    // Priority 2: Local OPML file
392 396
    if let Ok(content) = tokio::fs::read_to_string("feeds.opml").await {
393 397
        let urls = parse_opml(&content);
394 398
        if !urls.is_empty() {
397 401
        }
398 402
    }
399 403
400 -
    // Priority 3: FreshRSS fallback
401 -
    if let Some((freshrss_url, username, password)) = crate::freshrss_env() {
402 -
        let items = fetch_freshrss_items(&freshrss_url, &username, &password).await?;
404 +
    if let Some(config) = freshrss_config {
405 +
        let items = fetch_freshrss_items(config).await?;
403 406
        return Ok((items, None));
404 407
    }
405 408
406 -
    // Priority 4: DEFAULT_FEED env var
407 409
    if let Ok(default_feed) = std::env::var("DEFAULT_FEED") {
408 410
        let urls: Vec<String> = default_feed
409 411
            .split(',')
apps/feeds/src/main.rs +19 −30
25 25
    admin_password: Option<String>,
26 26
    cookie_secure: bool,
27 27
    base_url: String,
28 +
    freshrss_config: Option<feeds::FreshRSSConfig>,
28 29
}
29 30
30 31
struct TemplateFeedItem {
64 65
        .unwrap_or_default()
65 66
}
66 67
67 -
fn freshrss_env() -> Option<(String, String, String)> {
68 -
    let url = std::env::var("FRESHRSS_URL").ok()?;
69 -
    let username = std::env::var("FRESHRSS_USERNAME").ok()?;
70 -
    let password = std::env::var("FRESHRSS_PASSWORD").ok()?;
71 -
    Some((url, username, password))
72 -
}
73 -
74 68
async fn index_handler(
75 69
    State(state): State<Arc<AppState>>,
76 70
    Query(params): Query<HashMap<String, String>>,
80 74
        .or_else(|| params.get("urls"))
81 75
        .map(|s| s.as_str());
82 76
83 -
    let template = match feeds::get_feed_items(url_query).await {
77 +
    let template = match feeds::get_feed_items(url_query, state.freshrss_config.as_ref()).await {
84 78
        Ok((items, feed_urls)) => {
85 79
            let template_items: Vec<TemplateFeedItem> = items
86 80
                .into_iter()
114 108
}
115 109
116 110
async fn feeds_handler(
111 +
    State(state): State<Arc<AppState>>,
117 112
    Query(params): Query<HashMap<String, String>>,
118 113
) -> Result<Response, StatusCode> {
119 114
    let format = params
121 116
        .map(|s| s.as_str())
122 117
        .unwrap_or("json");
123 118
124 -
    let freshrss_url =
125 -
        std::env::var("FRESHRSS_URL").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
126 -
    let username =
127 -
        std::env::var("FRESHRSS_USERNAME").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
128 -
    let password =
129 -
        std::env::var("FRESHRSS_PASSWORD").map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
119 +
    let config = state
120 +
        .freshrss_config
121 +
        .as_ref()
122 +
        .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
130 123
131 -
    let data = feeds::fetch_freshrss_subscriptions(&freshrss_url, &username, &password)
124 +
    let data = feeds::fetch_freshrss_subscriptions(config)
132 125
        .await
133 126
        .map_err(|e| {
134 127
            eprintln!("Failed to fetch subscriptions: {e}");
334 327
    State(state): State<Arc<AppState>>,
335 328
    Query(q): Query<FlashQuery>,
336 329
) -> Response {
337 -
    let _ = state; // state available if needed later
338 -
339 -
    let freshrss_configured = freshrss_env().is_some();
330 +
    let freshrss_configured = state.freshrss_config.is_some();
340 331
341 -
    let subscriptions = if freshrss_configured {
342 -
        if let Some((url, user, pass)) = freshrss_env() {
343 -
            feeds::fetch_freshrss_subscriptions(&url, &user, &pass)
344 -
                .await
345 -
                .ok()
346 -
                .and_then(|list| list.subscriptions)
347 -
        } else {
348 -
            None
349 -
        }
332 +
    let subscriptions = if let Some(config) = &state.freshrss_config {
333 +
        feeds::fetch_freshrss_subscriptions(config)
334 +
            .await
335 +
            .ok()
336 +
            .and_then(|list| list.subscriptions)
350 337
    } else {
351 338
        None
352 339
    };
366 353
367 354
async fn add_feed_handler(
368 355
    _session: auth::AuthSession,
356 +
    State(state): State<Arc<AppState>>,
369 357
    Form(form): Form<AddFeedForm>,
370 358
) -> Response {
371 -
    let (url, user, pass) = match freshrss_env() {
372 -
        Some(env) => env,
359 +
    let config = match &state.freshrss_config {
360 +
        Some(c) => c,
373 361
        None => {
374 362
            return Redirect::to("/admin?error=FreshRSS+not+configured").into_response();
375 363
        }
376 364
    };
377 365
378 -
    match feeds::add_freshrss_subscription(&url, &user, &pass, &form.feed_url).await {
366 +
    match feeds::add_freshrss_subscription(config, &form.feed_url).await {
379 367
        Ok(_) => Redirect::to("/admin?success=Feed+added+successfully").into_response(),
380 368
        Err(e) => {
381 369
            eprintln!("Failed to add feed: {e}");
400 388
        admin_password: std::env::var("ADMIN_PASSWORD").ok(),
401 389
        cookie_secure,
402 390
        base_url,
391 +
        freshrss_config: feeds::FreshRSSConfig::from_env(),
403 392
    });
404 393
405 394
    let app = Router::new()
apps/feeds/src/models.rs +3 −1
1 -
#![allow(dead_code)]
2 1
use serde::{Deserialize, Serialize};
3 2
4 3
#[derive(Debug, Clone, Serialize, Deserialize)]
11 10
    pub origin: String,
12 11
}
13 12
13 +
#[allow(dead_code)]
14 14
#[derive(Debug, Deserialize)]
15 15
pub struct FreshRSSResponse {
16 16
    pub id: String,
19 19
    pub continuation: Option<String>,
20 20
}
21 21
22 +
#[allow(dead_code)]
22 23
#[derive(Debug, Deserialize)]
23 24
pub struct FreshRSSItem {
24 25
    pub id: String,
34 35
    pub href: String,
35 36
}
36 37
38 +
#[allow(dead_code)]
37 39
#[derive(Debug, Clone, Deserialize)]
38 40
pub struct FreshRSSOrigin {
39 41
    #[serde(rename = "streamId")]