feat: initial feeds aggregator b8edb0d4
Steve · 2026-04-17 22:33 14 file(s) · +2088 −654
Cargo.lock +5 −0
76 76
dependencies = [
77 77
 "axum",
78 78
 "rusqlite",
79 +
 "serde",
79 80
 "tracing",
80 81
]
81 82
1363 1364
version = "0.1.3"
1364 1365
dependencies = [
1365 1366
 "andromeda-auth",
1367 +
 "andromeda-db",
1366 1368
 "askama 0.13.1",
1367 1369
 "axum",
1368 1370
 "chrono",
1372 1374
 "quick-xml 0.37.5",
1373 1375
 "rand 0.8.5",
1374 1376
 "reqwest 0.12.28",
1377 +
 "rusqlite",
1375 1378
 "rust-embed",
1376 1379
 "scraper",
1377 1380
 "serde",
1378 1381
 "serde_json",
1379 1382
 "subtle",
1380 1383
 "tokio",
1384 +
 "tracing",
1385 +
 "tracing-subscriber",
1381 1386
 "url",
1382 1387
 "urlencoding",
1383 1388
]
apps/feeds/.env.example +4 −4
3 3
BASE_URL=http://localhost:3000
4 4
HOST=127.0.0.1
5 5
PORT=3000
6 -
DEFAULT_FEED=
7 -
FRESHRSS_URL=
8 -
FRESHRSS_USERNAME=
9 -
FRESHRSS_PASSWORD=
6 +
DB_PATH=feeds.sqlite
7 +
API_KEY=
8 +
DEFAULT_POLL_MINUTES=30
9 +
ITEM_CAP_PER_FEED=200
apps/feeds/Cargo.toml +5 −1
8 8
homepage = "https://github.com/stevedylandev/andromeda"
9 9
10 10
[dependencies]
11 -
axum = { workspace = true }
11 +
axum = { workspace = true, features = ["multipart"] }
12 12
tokio = { workspace = true }
13 13
serde = { workspace = true }
14 14
serde_json = { workspace = true }
16 16
rust-embed = { workspace = true }
17 17
subtle = { workspace = true }
18 18
rand = { workspace = true }
19 +
rusqlite = { workspace = true }
20 +
tracing = { workspace = true }
21 +
tracing-subscriber = { workspace = true, features = ["env-filter"] }
19 22
andromeda-auth = { workspace = true }
23 +
andromeda-db = { workspace = true, features = ["axum", "session", "feeds"] }
20 24
askama = "0.13"
21 25
reqwest = { version = "0.12", features = ["json"] }
22 26
feed-rs = "2"
apps/feeds/src/api.rs (added) +466 −0
1 +
use std::sync::Arc;
2 +
3 +
use andromeda_db::feeds as fdb;
4 +
use axum::{
5 +
    extract::{Multipart, Path, Query, State},
6 +
    http::StatusCode,
7 +
    response::{IntoResponse, Response},
8 +
    Json,
9 +
};
10 +
use serde::Deserialize;
11 +
12 +
use crate::auth::ApiAuth;
13 +
use crate::feeds::{discover_feeds, fetch_feed, parse_opml};
14 +
use crate::poller::POLL_INTERVAL_KEY;
15 +
use crate::AppState;
16 +
17 +
fn err_json(status: StatusCode, msg: impl Into<String>) -> Response {
18 +
    (
19 +
        status,
20 +
        Json(serde_json::json!({ "error": msg.into() })),
21 +
    )
22 +
        .into_response()
23 +
}
24 +
25 +
// ── Items ─────────────────────────────────────────────────────────────
26 +
27 +
#[derive(Deserialize)]
28 +
pub struct ListItemsQuery {
29 +
    limit: Option<i64>,
30 +
    #[serde(default)]
31 +
    unread: bool,
32 +
    category_id: Option<i64>,
33 +
    subscription_id: Option<i64>,
34 +
}
35 +
36 +
pub async fn list_items(
37 +
    _auth: ApiAuth,
38 +
    State(state): State<Arc<AppState>>,
39 +
    Query(q): Query<ListItemsQuery>,
40 +
) -> Response {
41 +
    let filter = fdb::ListItemsFilter {
42 +
        limit: q.limit,
43 +
        unread_only: q.unread,
44 +
        category_id: q.category_id,
45 +
        subscription_id: q.subscription_id,
46 +
    };
47 +
    match fdb::list_items(&state.db, &filter) {
48 +
        Ok(items) => Json(serde_json::json!({ "items": items })).into_response(),
49 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
50 +
    }
51 +
}
52 +
53 +
pub async fn mark_item_read(
54 +
    _auth: ApiAuth,
55 +
    State(state): State<Arc<AppState>>,
56 +
    Path(id): Path<i64>,
57 +
) -> Response {
58 +
    match fdb::mark_read(&state.db, id) {
59 +
        Ok(true) => Json(serde_json::json!({ "ok": true, "is_read": true })).into_response(),
60 +
        Ok(false) => err_json(StatusCode::NOT_FOUND, "item not found"),
61 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
62 +
    }
63 +
}
64 +
65 +
pub async fn mark_item_unread(
66 +
    _auth: ApiAuth,
67 +
    State(state): State<Arc<AppState>>,
68 +
    Path(id): Path<i64>,
69 +
) -> Response {
70 +
    match fdb::mark_unread(&state.db, id) {
71 +
        Ok(true) => Json(serde_json::json!({ "ok": true, "is_read": false })).into_response(),
72 +
        Ok(false) => err_json(StatusCode::NOT_FOUND, "item not found"),
73 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
74 +
    }
75 +
}
76 +
77 +
// ── Subscriptions ─────────────────────────────────────────────────────
78 +
79 +
pub async fn list_subscriptions(
80 +
    _auth: ApiAuth,
81 +
    State(state): State<Arc<AppState>>,
82 +
) -> Response {
83 +
    match fdb::list_subscriptions(&state.db) {
84 +
        Ok(subs) => Json(serde_json::json!({ "subscriptions": subs })).into_response(),
85 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
86 +
    }
87 +
}
88 +
89 +
#[derive(Deserialize)]
90 +
pub struct CreateSubscriptionBody {
91 +
    pub feed_url: String,
92 +
    pub title: Option<String>,
93 +
    pub category_id: Option<i64>,
94 +
    pub category_name: Option<String>,
95 +
}
96 +
97 +
pub async fn create_subscription(
98 +
    _auth: ApiAuth,
99 +
    State(state): State<Arc<AppState>>,
100 +
    Json(body): Json<CreateSubscriptionBody>,
101 +
) -> Response {
102 +
    add_subscription(&state, &body).await
103 +
}
104 +
105 +
pub async fn add_subscription(
106 +
    state: &AppState,
107 +
    body: &CreateSubscriptionBody,
108 +
) -> Response {
109 +
    let feed_url = body.feed_url.trim();
110 +
    if feed_url.is_empty() {
111 +
        return err_json(StatusCode::BAD_REQUEST, "feed_url required");
112 +
    }
113 +
114 +
    if let Ok(Some(existing)) = fdb::get_subscription_by_url(&state.db, feed_url) {
115 +
        return (
116 +
            StatusCode::CONFLICT,
117 +
            Json(serde_json::json!({
118 +
                "error": "already subscribed",
119 +
                "subscription": existing
120 +
            })),
121 +
        )
122 +
            .into_response();
123 +
    }
124 +
125 +
    // Probe once to resolve title + site_url.
126 +
    let probed = fetch_feed(feed_url, None, None).await;
127 +
    let (title, site_url, etag, last_modified) = match probed {
128 +
        Ok(r) => (
129 +
            body.title
130 +
                .clone()
131 +
                .or(r.title)
132 +
                .unwrap_or_else(|| feed_url.to_string()),
133 +
            r.site_url,
134 +
            r.etag,
135 +
            r.last_modified,
136 +
        ),
137 +
        Err(e) => {
138 +
            return err_json(
139 +
                StatusCode::BAD_REQUEST,
140 +
                format!("feed not reachable: {e}"),
141 +
            );
142 +
        }
143 +
    };
144 +
145 +
    let category_id = match resolve_category(state, body.category_id, body.category_name.as_deref())
146 +
    {
147 +
        Ok(id) => id,
148 +
        Err(resp) => return resp,
149 +
    };
150 +
151 +
    let sub = match fdb::insert_subscription(
152 +
        &state.db,
153 +
        feed_url,
154 +
        &title,
155 +
        site_url.as_deref(),
156 +
        category_id,
157 +
    ) {
158 +
        Ok(s) => s,
159 +
        Err(e) => return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
160 +
    };
161 +
162 +
    // Seed items immediately so the UI isn't empty until the next poll.
163 +
    let _ = fdb::update_subscription_meta(
164 +
        &state.db,
165 +
        sub.id,
166 +
        etag.as_deref(),
167 +
        last_modified.as_deref(),
168 +
        &chrono::Utc::now().format("%Y-%m-%d %H:%M:%S").to_string(),
169 +
        None,
170 +
    );
171 +
    if let Ok(result) = fetch_feed(feed_url, None, None).await {
172 +
        for entry in &result.entries {
173 +
            if entry.link.is_empty() {
174 +
                continue;
175 +
            }
176 +
            let _ = fdb::insert_item_ignore_dup(
177 +
                &state.db,
178 +
                &fdb::NewItem {
179 +
                    subscription_id: sub.id,
180 +
                    guid: &entry.guid,
181 +
                    title: &entry.title,
182 +
                    link: &entry.link,
183 +
                    author: entry.author.as_deref(),
184 +
                    published_at: entry.published_at,
185 +
                },
186 +
            );
187 +
        }
188 +
        let _ = fdb::prune_subscription(&state.db, sub.id, state.item_cap as i64);
189 +
    }
190 +
191 +
    (StatusCode::CREATED, Json(serde_json::json!({ "subscription": sub }))).into_response()
192 +
}
193 +
194 +
fn resolve_category(
195 +
    state: &AppState,
196 +
    id: Option<i64>,
197 +
    name: Option<&str>,
198 +
) -> Result<Option<i64>, Response> {
199 +
    if let Some(id) = id {
200 +
        return Ok(Some(id));
201 +
    }
202 +
    if let Some(raw) = name {
203 +
        let trimmed = raw.trim();
204 +
        if !trimmed.is_empty() {
205 +
            let cat = fdb::get_or_create_category(&state.db, trimmed)
206 +
                .map_err(|e| err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
207 +
            return Ok(Some(cat.id));
208 +
        }
209 +
    }
210 +
    Ok(None)
211 +
}
212 +
213 +
#[derive(Deserialize)]
214 +
pub struct UpdateSubscriptionBody {
215 +
    category_id: Option<i64>,
216 +
    category_name: Option<String>,
217 +
    clear_category: Option<bool>,
218 +
}
219 +
220 +
pub async fn update_subscription(
221 +
    _auth: ApiAuth,
222 +
    State(state): State<Arc<AppState>>,
223 +
    Path(id): Path<i64>,
224 +
    Json(body): Json<UpdateSubscriptionBody>,
225 +
) -> Response {
226 +
    let category_id = if body.clear_category.unwrap_or(false) {
227 +
        None
228 +
    } else {
229 +
        match resolve_category(&state, body.category_id, body.category_name.as_deref()) {
230 +
            Ok(v) => v,
231 +
            Err(resp) => return resp,
232 +
        }
233 +
    };
234 +
235 +
    match fdb::update_subscription_category(&state.db, id, category_id) {
236 +
        Ok(()) => Json(serde_json::json!({ "ok": true })).into_response(),
237 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
238 +
    }
239 +
}
240 +
241 +
pub async fn delete_subscription(
242 +
    _auth: ApiAuth,
243 +
    State(state): State<Arc<AppState>>,
244 +
    Path(id): Path<i64>,
245 +
) -> Response {
246 +
    match fdb::delete_subscription(&state.db, id) {
247 +
        Ok(true) => Json(serde_json::json!({ "ok": true })).into_response(),
248 +
        Ok(false) => err_json(StatusCode::NOT_FOUND, "subscription not found"),
249 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
250 +
    }
251 +
}
252 +
253 +
// ── Categories ────────────────────────────────────────────────────────
254 +
255 +
pub async fn list_categories(
256 +
    _auth: ApiAuth,
257 +
    State(state): State<Arc<AppState>>,
258 +
) -> Response {
259 +
    match fdb::list_categories(&state.db) {
260 +
        Ok(cats) => Json(serde_json::json!({ "categories": cats })).into_response(),
261 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
262 +
    }
263 +
}
264 +
265 +
#[derive(Deserialize)]
266 +
pub struct CreateCategoryBody {
267 +
    name: String,
268 +
}
269 +
270 +
pub async fn create_category(
271 +
    _auth: ApiAuth,
272 +
    State(state): State<Arc<AppState>>,
273 +
    Json(body): Json<CreateCategoryBody>,
274 +
) -> Response {
275 +
    let name = body.name.trim();
276 +
    if name.is_empty() {
277 +
        return err_json(StatusCode::BAD_REQUEST, "name required");
278 +
    }
279 +
    match fdb::get_or_create_category(&state.db, name) {
280 +
        Ok(cat) => (StatusCode::CREATED, Json(serde_json::json!({ "category": cat }))).into_response(),
281 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
282 +
    }
283 +
}
284 +
285 +
pub async fn delete_category(
286 +
    _auth: ApiAuth,
287 +
    State(state): State<Arc<AppState>>,
288 +
    Path(id): Path<i64>,
289 +
) -> Response {
290 +
    match fdb::delete_category(&state.db, id) {
291 +
        Ok(true) => Json(serde_json::json!({ "ok": true })).into_response(),
292 +
        Ok(false) => err_json(StatusCode::NOT_FOUND, "category not found"),
293 +
        Err(e) => err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()),
294 +
    }
295 +
}
296 +
297 +
// ── OPML import ───────────────────────────────────────────────────────
298 +
299 +
pub async fn import_opml(
300 +
    _auth: ApiAuth,
301 +
    State(state): State<Arc<AppState>>,
302 +
    mut multipart: Multipart,
303 +
) -> Response {
304 +
    let mut content: Option<String> = None;
305 +
    while let Ok(Some(field)) = multipart.next_field().await {
306 +
        if field.name() == Some("file") {
307 +
            match field.text().await {
308 +
                Ok(s) => content = Some(s),
309 +
                Err(e) => {
310 +
                    return err_json(StatusCode::BAD_REQUEST, format!("read file failed: {e}"));
311 +
                }
312 +
            }
313 +
        }
314 +
    }
315 +
316 +
    let content = match content {
317 +
        Some(c) => c,
318 +
        None => return err_json(StatusCode::BAD_REQUEST, "missing `file` field"),
319 +
    };
320 +
321 +
    let result = import_opml_str(state, &content).await;
322 +
    Json(serde_json::json!(result)).into_response()
323 +
}
324 +
325 +
#[derive(serde::Serialize)]
326 +
pub struct ImportSummary {
327 +
    pub imported: usize,
328 +
    pub skipped: usize,
329 +
    pub failed: Vec<String>,
330 +
}
331 +
332 +
const SEED_CONCURRENCY: usize = 8;
333 +
334 +
pub async fn import_opml_str(state: Arc<AppState>, content: &str) -> ImportSummary {
335 +
    let entries = parse_opml(content);
336 +
    let mut imported = 0usize;
337 +
    let mut skipped = 0usize;
338 +
    let mut failed: Vec<String> = Vec::new();
339 +
    let sem = Arc::new(tokio::sync::Semaphore::new(SEED_CONCURRENCY));
340 +
    let mut seed_handles: Vec<tokio::task::JoinHandle<Option<String>>> = Vec::new();
341 +
342 +
    for entry in entries {
343 +
        if let Ok(Some(_)) = fdb::get_subscription_by_url(&state.db, &entry.xml_url) {
344 +
            skipped += 1;
345 +
            continue;
346 +
        }
347 +
348 +
        let category_id = entry
349 +
            .category
350 +
            .as_deref()
351 +
            .and_then(|name| fdb::get_or_create_category(&state.db, name).ok())
352 +
            .map(|c| c.id);
353 +
354 +
        let title = entry
355 +
            .title
356 +
            .clone()
357 +
            .unwrap_or_else(|| entry.xml_url.clone());
358 +
        let site_url = entry.html_url.clone();
359 +
360 +
        match fdb::insert_subscription(
361 +
            &state.db,
362 +
            &entry.xml_url,
363 +
            &title,
364 +
            site_url.as_deref(),
365 +
            category_id,
366 +
        ) {
367 +
            Ok(sub) => {
368 +
                imported += 1;
369 +
                let state_cloned = Arc::clone(&state);
370 +
                let sem_cloned = Arc::clone(&sem);
371 +
                seed_handles.push(tokio::spawn(async move {
372 +
                    let _permit = match sem_cloned.acquire().await {
373 +
                        Ok(p) => p,
374 +
                        Err(_) => return None,
375 +
                    };
376 +
                    crate::poller::poll_one(&state_cloned, &sub)
377 +
                        .await
378 +
                        .err()
379 +
                        .map(|e| format!("{}: seed failed: {}", sub.feed_url, e))
380 +
                }));
381 +
            }
382 +
            Err(e) => failed.push(format!("{}: {}", entry.xml_url, e)),
383 +
        }
384 +
    }
385 +
386 +
    for h in seed_handles {
387 +
        if let Ok(Some(msg)) = h.await {
388 +
            failed.push(msg);
389 +
        }
390 +
    }
391 +
392 +
    ImportSummary {
393 +
        imported,
394 +
        skipped,
395 +
        failed,
396 +
    }
397 +
}
398 +
399 +
// ── Settings ──────────────────────────────────────────────────────────
400 +
401 +
#[derive(serde::Serialize)]
402 +
struct SettingsView {
403 +
    poll_interval_minutes: u64,
404 +
    default_poll_minutes: u64,
405 +
    item_cap_per_feed: usize,
406 +
    api_key_configured: bool,
407 +
}
408 +
409 +
pub async fn get_settings(
410 +
    _auth: ApiAuth,
411 +
    State(state): State<Arc<AppState>>,
412 +
) -> Response {
413 +
    let poll = fdb::get_setting(&state.db, POLL_INTERVAL_KEY)
414 +
        .ok()
415 +
        .flatten()
416 +
        .and_then(|v| v.parse::<u64>().ok())
417 +
        .unwrap_or(state.default_poll_minutes);
418 +
    let view = SettingsView {
419 +
        poll_interval_minutes: poll,
420 +
        default_poll_minutes: state.default_poll_minutes,
421 +
        item_cap_per_feed: state.item_cap,
422 +
        api_key_configured: state.api_key.is_some(),
423 +
    };
424 +
    Json(serde_json::json!(view)).into_response()
425 +
}
426 +
427 +
#[derive(Deserialize)]
428 +
pub struct UpdateSettingsBody {
429 +
    poll_interval_minutes: Option<u64>,
430 +
}
431 +
432 +
pub async fn update_settings(
433 +
    _auth: ApiAuth,
434 +
    State(state): State<Arc<AppState>>,
435 +
    Json(body): Json<UpdateSettingsBody>,
436 +
) -> Response {
437 +
    if let Some(mins) = body.poll_interval_minutes {
438 +
        if !(1..=1440).contains(&mins) {
439 +
            return err_json(
440 +
                StatusCode::BAD_REQUEST,
441 +
                "poll_interval_minutes must be between 1 and 1440",
442 +
            );
443 +
        }
444 +
        if let Err(e) = fdb::set_setting(&state.db, POLL_INTERVAL_KEY, &mins.to_string()) {
445 +
            return err_json(StatusCode::INTERNAL_SERVER_ERROR, e.to_string());
446 +
        }
447 +
    }
448 +
    Json(serde_json::json!({ "ok": true })).into_response()
449 +
}
450 +
451 +
// ── Discover ──────────────────────────────────────────────────────────
452 +
453 +
#[derive(Deserialize)]
454 +
pub struct DiscoverBody {
455 +
    base_url: String,
456 +
}
457 +
458 +
pub async fn discover(
459 +
    _auth: ApiAuth,
460 +
    Json(body): Json<DiscoverBody>,
461 +
) -> Response {
462 +
    match discover_feeds(&body.base_url).await {
463 +
        Ok(feeds) => Json(serde_json::json!({ "feeds": feeds })).into_response(),
464 +
        Err(e) => err_json(StatusCode::BAD_REQUEST, e),
465 +
    }
466 +
}
apps/feeds/src/auth.rs +64 −32
1 1
use axum::{
2 2
    extract::{FromRef, FromRequestParts},
3 -
    http::request::Parts,
3 +
    http::{request::Parts, StatusCode},
4 4
    response::{IntoResponse, Redirect, Response},
5 5
};
6 -
use std::collections::HashMap;
7 -
use std::sync::{Arc, Mutex};
8 -
use std::time::{Duration, Instant};
6 +
use chrono::{Duration, Utc};
7 +
use std::sync::Arc;
9 8
10 9
use crate::AppState;
10 +
use andromeda_db::session;
11 11
12 12
pub use andromeda_auth::{
13 13
    build_session_cookie, clear_session_cookie, extract_session_cookie, generate_session_token,
14 -
    verify_password,
14 +
    verify_api_key, verify_password,
15 15
};
16 16
17 -
pub type SessionStore = Arc<Mutex<HashMap<String, Instant>>>;
18 -
19 -
const SESSION_TTL: Duration = Duration::from_secs(7 * 24 * 60 * 60); // 7 days
20 -
21 -
pub fn new_session_store() -> SessionStore {
22 -
    Arc::new(Mutex::new(HashMap::new()))
23 -
}
17 +
const SESSION_DAYS: i64 = 7;
24 18
25 -
pub fn create_session(store: &SessionStore, token: &str) {
26 -
    if let Ok(mut sessions) = store.lock() {
27 -
        sessions.insert(token.to_string(), Instant::now());
28 -
    }
19 +
/// Create a session row with 7-day expiry.
20 +
pub fn create_session(db: &andromeda_db::Db, token: &str) -> Result<(), andromeda_db::DbError> {
21 +
    let expires = (Utc::now() + Duration::days(SESSION_DAYS))
22 +
        .format("%Y-%m-%d %H:%M:%S")
23 +
        .to_string();
24 +
    session::insert_session(db, token, &expires)
29 25
}
30 26
31 -
pub fn is_valid_session(store: &SessionStore, token: &str) -> bool {
32 -
    if let Ok(mut sessions) = store.lock() {
33 -
        if let Some(created) = sessions.get(token) {
34 -
            if created.elapsed() < SESSION_TTL {
35 -
                return true;
36 -
            }
37 -
            sessions.remove(token);
27 +
pub fn is_valid_session(db: &andromeda_db::Db, token: &str) -> bool {
28 +
    match session::get_session_expiry(db, token) {
29 +
        Ok(Some(expires_at)) => {
30 +
            chrono::NaiveDateTime::parse_from_str(&expires_at, "%Y-%m-%d %H:%M:%S")
31 +
                .map(|exp| exp > Utc::now().naive_utc())
32 +
                .unwrap_or(false)
38 33
        }
34 +
        _ => false,
39 35
    }
40 -
    false
41 36
}
42 37
43 -
pub fn delete_session(store: &SessionStore, token: &str) {
44 -
    if let Ok(mut sessions) = store.lock() {
45 -
        sessions.remove(token);
46 -
    }
38 +
pub fn delete_session(db: &andromeda_db::Db, token: &str) {
39 +
    let _ = session::delete_session(db, token);
47 40
}
48 41
49 -
/// Axum extractor — guards routes behind login. Redirects to /admin/login if invalid.
42 +
/// Guards browser admin routes. Redirects to login on failure.
50 43
pub struct AuthSession;
51 44
52 45
impl<S> FromRequestParts<S> for AuthSession
58 51
59 52
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
60 53
        let state = Arc::<AppState>::from_ref(state);
61 -
        let token = extract_session_cookie(&parts.headers);
62 -
        if let Some(token) = token {
63 -
            if is_valid_session(&state.sessions, &token) {
54 +
        if let Some(token) = extract_session_cookie(&parts.headers) {
55 +
            if is_valid_session(&state.db, &token) {
64 56
                return Ok(AuthSession);
65 57
            }
66 58
        }
67 59
        Err(Redirect::to("/admin/login").into_response())
68 60
    }
69 61
}
62 +
63 +
/// Guards JSON API routes. Accepts `Authorization: Bearer <API_KEY>` OR a valid session cookie.
64 +
/// Returns 401 JSON on failure (doesn't redirect).
65 +
pub struct ApiAuth;
66 +
67 +
impl<S> FromRequestParts<S> for ApiAuth
68 +
where
69 +
    S: Send + Sync,
70 +
    Arc<AppState>: FromRef<S>,
71 +
{
72 +
    type Rejection = Response;
73 +
74 +
    async fn from_request_parts(parts: &mut Parts, state: &S) -> Result<Self, Self::Rejection> {
75 +
        let state = Arc::<AppState>::from_ref(state);
76 +
77 +
        if let Some(expected_key) = state.api_key.as_deref() {
78 +
            if let Some(header) = parts.headers.get(axum::http::header::AUTHORIZATION) {
79 +
                if let Ok(s) = header.to_str() {
80 +
                    if let Some(token) = s.strip_prefix("Bearer ").or_else(|| s.strip_prefix("bearer ")) {
81 +
                        if verify_api_key(token.trim(), expected_key) {
82 +
                            return Ok(ApiAuth);
83 +
                        }
84 +
                    }
85 +
                }
86 +
            }
87 +
        }
88 +
89 +
        if let Some(token) = extract_session_cookie(&parts.headers) {
90 +
            if is_valid_session(&state.db, &token) {
91 +
                return Ok(ApiAuth);
92 +
            }
93 +
        }
94 +
95 +
        Err((
96 +
            StatusCode::UNAUTHORIZED,
97 +
            axum::Json(serde_json::json!({ "error": "unauthorized" })),
98 +
        )
99 +
            .into_response())
100 +
    }
101 +
}
apps/feeds/src/feeds.rs +239 −325
1 -
use crate::models::{FeedItem, FreshRSSResponse, SubscriptionList};
1 +
use crate::models::FeedItem;
2 +
use quick_xml::events::Event;
2 3
use scraper::{Html, Selector};
3 4
use std::time::Duration;
4 5
use url::Url;
5 6
6 -
#[derive(Clone)]
7 -
pub struct FreshRSSConfig {
8 -
    pub url: String,
9 -
    pub username: String,
10 -
    pub password: String,
7 +
/// One outline entry from an OPML document (subscription plus optional category name).
8 +
#[derive(Debug, Clone, PartialEq, Eq)]
9 +
pub struct OpmlEntry {
10 +
    pub xml_url: String,
11 +
    pub title: Option<String>,
12 +
    pub html_url: Option<String>,
13 +
    pub category: Option<String>,
11 14
}
12 15
13 -
impl FreshRSSConfig {
14 -
    pub fn from_env() -> Option<Self> {
15 -
        Some(Self {
16 -
            url: std::env::var("FRESHRSS_URL").ok()?,
17 -
            username: std::env::var("FRESHRSS_USERNAME").ok()?,
18 -
            password: std::env::var("FRESHRSS_PASSWORD").ok()?,
19 -
        })
20 -
    }
16 +
/// Result of a conditional fetch against an RSS/Atom feed.
17 +
#[derive(Debug)]
18 +
pub struct FetchResult {
19 +
    /// HTTP status code. 304 means nothing changed; items will be empty.
20 +
    pub status: u16,
21 +
    pub etag: Option<String>,
22 +
    pub last_modified: Option<String>,
23 +
    pub title: Option<String>,
24 +
    pub site_url: Option<String>,
25 +
    pub entries: Vec<ParsedEntry>,
21 26
}
22 27
23 -
struct FreshRSSClient {
24 -
    client: reqwest::Client,
25 -
    base_url: String,
26 -
    token: String,
27 -
}
28 -
29 -
impl FreshRSSClient {
30 -
    async fn new(config: &FreshRSSConfig) -> Result<Self, String> {
31 -
        let client = build_client();
32 -
        let auth_url = format!(
33 -
            "{}/api/greader.php/accounts/ClientLogin?Email={}&Passwd={}",
34 -
            config.url, config.username, config.password
35 -
        );
36 -
37 -
        let text = client
38 -
            .get(&auth_url)
39 -
            .send()
40 -
            .await
41 -
            .map_err(|e| format!("Auth request failed: {e}"))?
42 -
            .text()
43 -
            .await
44 -
            .map_err(|e| format!("Failed to read auth response: {e}"))?;
45 -
46 -
        let token = text
47 -
            .lines()
48 -
            .find_map(|line| line.strip_prefix("Auth="))
49 -
            .map(|t| t.trim().to_string())
50 -
            .ok_or_else(|| "Authentication failed: no Auth token found".to_string())?;
51 -
52 -
        Ok(Self {
53 -
            client,
54 -
            base_url: config.url.clone(),
55 -
            token,
56 -
        })
57 -
    }
58 -
59 -
    fn api_url(&self, path: &str) -> String {
60 -
        format!("{}/api/greader.php/{}", self.base_url, path)
61 -
    }
62 -
63 -
    fn auth_get(&self, path: &str) -> reqwest::RequestBuilder {
64 -
        self.client
65 -
            .get(self.api_url(path))
66 -
            .header("Authorization", format!("GoogleLogin auth={}", self.token))
67 -
    }
68 -
69 -
    fn auth_post(&self, path: &str) -> reqwest::RequestBuilder {
70 -
        self.client
71 -
            .post(self.api_url(path))
72 -
            .header("Authorization", format!("GoogleLogin auth={}", self.token))
73 -
    }
74 -
75 -
    async fn fetch_items(&self) -> Result<Vec<FeedItem>, String> {
76 -
        let data: FreshRSSResponse = self
77 -
            .auth_get("reader/api/0/stream/contents/reading-list?n=60&r=d")
78 -
            .send()
79 -
            .await
80 -
            .map_err(|e| format!("Failed to fetch reading list: {e}"))?
81 -
            .json()
82 -
            .await
83 -
            .map_err(|e| format!("Failed to parse FreshRSS response: {e}"))?;
84 -
85 -
        let mut items: Vec<FeedItem> = data
86 -
            .items
87 -
            .iter()
88 -
            .map(|item| {
89 -
                let link = item
90 -
                    .canonical
91 -
                    .as_ref()
92 -
                    .and_then(|c| c.first())
93 -
                    .map(|l| l.href.clone())
94 -
                    .unwrap_or_default();
95 -
96 -
                FeedItem {
97 -
                    id: item.id.clone(),
98 -
                    title: item.title.clone(),
99 -
                    published: item.published,
100 -
                    author: item.origin.title.clone(),
101 -
                    link,
102 -
                    origin: item.origin.title.clone(),
103 -
                }
104 -
            })
105 -
            .collect();
106 -
107 -
        items.sort_by(|a, b| b.published.cmp(&a.published));
108 -
        Ok(items)
109 -
    }
110 -
111 -
    async fn fetch_subscriptions(&self) -> Result<SubscriptionList, String> {
112 -
        let response = self
113 -
            .auth_get("reader/api/0/subscription/list?output=json")
114 -
            .send()
115 -
            .await
116 -
            .map_err(|e| format!("Failed to fetch subscriptions: {e}"))?;
117 -
118 -
        if !response.status().is_success() {
119 -
            return Err(format!("FreshRSS API error: {}", response.status()));
120 -
        }
121 -
122 -
        response
123 -
            .json()
124 -
            .await
125 -
            .map_err(|e| format!("Failed to parse subscription list: {e}"))
126 -
    }
127 -
128 -
    async fn add_subscription(&self, feed_url: &str) -> Result<String, String> {
129 -
        let response = self
130 -
            .auth_post("reader/api/0/subscription/quickadd")
131 -
            .form(&[("quickadd", feed_url)])
132 -
            .send()
133 -
            .await
134 -
            .map_err(|e| format!("Failed to add subscription: {e}"))?;
135 -
136 -
        if !response.status().is_success() {
137 -
            let status = response.status();
138 -
            let body = response.text().await.unwrap_or_default();
139 -
            return Err(format!("FreshRSS API error ({}): {}", status, body));
140 -
        }
141 -
142 -
        let stream_id = format!("feed/{feed_url}");
143 -
        let response = self
144 -
            .auth_post("reader/api/0/subscription/edit")
145 -
            .form(&[
146 -
                ("ac", "edit"),
147 -
                ("s", &stream_id),
148 -
                ("a", "user/-/label/Feeds"),
149 -
            ])
150 -
            .send()
151 -
            .await
152 -
            .map_err(|e| format!("Feed added but failed to set category: {e}"))?;
153 -
154 -
        if !response.status().is_success() {
155 -
            let status = response.status();
156 -
            let body = response.text().await.unwrap_or_default();
157 -
            return Err(format!(
158 -
                "Feed added but failed to set category ({}): {}",
159 -
                status, body
160 -
            ));
161 -
        }
162 -
163 -
        Ok(format!("Successfully added feed: {feed_url}"))
164 -
    }
28 +
#[derive(Debug, Clone)]
29 +
pub struct ParsedEntry {
30 +
    pub guid: String,
31 +
    pub title: String,
32 +
    pub link: String,
33 +
    pub author: Option<String>,
34 +
    pub published_at: i64,
165 35
}
166 36
167 37
fn build_client() -> reqwest::Client {
168 38
    reqwest::Client::builder()
169 -
        .timeout(Duration::from_secs(5))
39 +
        .timeout(Duration::from_secs(15))
40 +
        .user_agent("andromeda-feeds/0.1 (+https://github.com/stevedylandev/andromeda)")
170 41
        .build()
171 42
        .expect("Failed to build HTTP client")
172 43
}
173 44
174 -
async fn fetch_feed_from_url(client: &reqwest::Client, url: &str) -> Vec<FeedItem> {
175 -
    let response = match client.get(url).send().await {
176 -
        Ok(r) => r,
177 -
        Err(e) => {
178 -
            eprintln!("Failed to fetch feed {url}: {e}");
179 -
            return Vec::new();
180 -
        }
181 -
    };
45 +
pub async fn fetch_feed(
46 +
    url: &str,
47 +
    etag: Option<&str>,
48 +
    last_modified: Option<&str>,
49 +
) -> Result<FetchResult, String> {
50 +
    let client = build_client();
51 +
    let mut req = client.get(url);
52 +
    if let Some(tag) = etag {
53 +
        req = req.header("If-None-Match", tag);
54 +
    }
55 +
    if let Some(lm) = last_modified {
56 +
        req = req.header("If-Modified-Since", lm);
57 +
    }
58 +
59 +
    let resp = req.send().await.map_err(|e| format!("fetch failed: {e}"))?;
60 +
    let status = resp.status().as_u16();
61 +
    let headers = resp.headers().clone();
62 +
    let new_etag = headers
63 +
        .get(reqwest::header::ETAG)
64 +
        .and_then(|v| v.to_str().ok())
65 +
        .map(|s| s.to_string());
66 +
    let new_last_modified = headers
67 +
        .get(reqwest::header::LAST_MODIFIED)
68 +
        .and_then(|v| v.to_str().ok())
69 +
        .map(|s| s.to_string());
182 70
183 -
    let body = match response.bytes().await {
184 -
        Ok(b) => b,
185 -
        Err(e) => {
186 -
            eprintln!("Failed to read feed body {url}: {e}");
187 -
            return Vec::new();
188 -
        }
189 -
    };
71 +
    if status == 304 {
72 +
        return Ok(FetchResult {
73 +
            status,
74 +
            etag: new_etag.or_else(|| etag.map(|s| s.to_string())),
75 +
            last_modified: new_last_modified.or_else(|| last_modified.map(|s| s.to_string())),
76 +
            title: None,
77 +
            site_url: None,
78 +
            entries: Vec::new(),
79 +
        });
80 +
    }
190 81
191 -
    let feed = match feed_rs::parser::parse(&body[..]) {
192 -
        Ok(f) => f,
193 -
        Err(e) => {
194 -
            eprintln!("Failed to parse feed {url}: {e}");
195 -
            return Vec::new();
196 -
        }
197 -
    };
82 +
    if !resp.status().is_success() {
83 +
        return Err(format!("upstream returned {status}"));
84 +
    }
198 85
199 -
    let feed_title = feed
200 -
        .title
201 -
        .as_ref()
202 -
        .map(|t| t.content.clone())
203 -
        .unwrap_or_default();
86 +
    let body = resp
87 +
        .bytes()
88 +
        .await
89 +
        .map_err(|e| format!("read body failed: {e}"))?;
90 +
    let feed =
91 +
        feed_rs::parser::parse(&body[..]).map_err(|e| format!("feed parse failed: {e}"))?;
204 92
205 -
    feed.entries
93 +
    let title = feed.title.as_ref().map(|t| t.content.clone());
94 +
    let site_url = feed
95 +
        .links
206 96
        .iter()
97 +
        .find(|l| l.rel.as_deref() != Some("self"))
98 +
        .map(|l| l.href.clone())
99 +
        .or_else(|| feed.links.first().map(|l| l.href.clone()));
100 +
101 +
    let entries = feed
102 +
        .entries
103 +
        .into_iter()
207 104
        .map(|entry| {
208 -
            let published = entry
105 +
            let published_at = entry
209 106
                .published
210 107
                .or(entry.updated)
211 108
                .map(|dt| dt.timestamp())
212 109
                .unwrap_or(0);
213 -
214 110
            let link = entry
215 111
                .links
216 112
                .first()
217 113
                .map(|l| l.href.clone())
218 114
                .unwrap_or_default();
219 -
220 115
            let title = entry
221 116
                .title
222 117
                .as_ref()
223 118
                .map(|t| t.content.clone())
224 119
                .unwrap_or_default();
225 -
226 -
            let id = entry.id.clone();
227 -
228 -
            let entry_author = entry
229 -
                .authors
230 -
                .first()
231 -
                .map(|a| a.name.clone())
232 -
                .unwrap_or_default();
233 -
234 -
            let author = if entry_author.is_empty() {
235 -
                feed_title.clone()
120 +
            let author = entry.authors.first().map(|a| a.name.clone());
121 +
            let guid = if !entry.id.is_empty() {
122 +
                entry.id
236 123
            } else {
237 -
                format!("{} - {}", feed_title, entry_author)
124 +
                link.clone()
238 125
            };
239 -
240 -
            FeedItem {
241 -
                id,
126 +
            ParsedEntry {
127 +
                guid,
242 128
                title,
243 -
                published,
129 +
                link,
244 130
                author,
245 -
                link,
246 -
                origin: feed_title.clone(),
131 +
                published_at,
247 132
            }
248 133
        })
249 -
        .collect()
134 +
        .collect();
135 +
136 +
    Ok(FetchResult {
137 +
        status,
138 +
        etag: new_etag,
139 +
        last_modified: new_last_modified,
140 +
        title,
141 +
        site_url,
142 +
        entries,
143 +
    })
250 144
}
251 145
252 -
pub async fn parse_urls(urls: &[String]) -> Vec<FeedItem> {
253 -
    let client = build_client();
146 +
/// Ad-hoc preview: parse one or more feed URLs and return flattened items.
147 +
/// Kept for the `?url=` bypass mode on the index page.
148 +
pub async fn preview_urls(urls: &[String]) -> Vec<FeedItem> {
254 149
    let mut handles = Vec::new();
255 -
256 150
    for url in urls {
257 -
        let client = client.clone();
258 151
        let url = url.clone();
259 152
        handles.push(tokio::spawn(async move {
260 -
            fetch_feed_from_url(&client, &url).await
153 +
            let result = fetch_feed(&url, None, None).await;
154 +
            match result {
155 +
                Ok(r) => {
156 +
                    let feed_title = r.title.clone().unwrap_or_default();
157 +
                    r.entries
158 +
                        .into_iter()
159 +
                        .map(|e| FeedItem {
160 +
                            title: e.title,
161 +
                            link: e.link,
162 +
                            published: e.published_at,
163 +
                            author: match e.author {
164 +
                                Some(a) if !a.is_empty() && !feed_title.is_empty() => {
165 +
                                    format!("{feed_title} - {a}")
166 +
                                }
167 +
                                Some(a) if !a.is_empty() => a,
168 +
                                _ => feed_title.clone(),
169 +
                            },
170 +
                        })
171 +
                        .collect::<Vec<_>>()
172 +
                }
173 +
                Err(e) => {
174 +
                    tracing::warn!("preview fetch failed for {url}: {e}");
175 +
                    Vec::new()
176 +
                }
177 +
            }
261 178
        }));
262 179
    }
263 180
264 -
    let mut all_items = Vec::new();
265 -
    for handle in handles {
266 -
        if let Ok(items) = handle.await {
267 -
            all_items.extend(items);
181 +
    let mut all = Vec::new();
182 +
    for h in handles {
183 +
        if let Ok(items) = h.await {
184 +
            all.extend(items);
268 185
        }
269 186
    }
270 -
271 -
    all_items.sort_by(|a, b| b.published.cmp(&a.published));
272 -
    all_items
187 +
    all.sort_by(|a, b| b.published.cmp(&a.published));
188 +
    all
273 189
}
274 190
275 -
pub fn parse_opml(content: &str) -> Vec<String> {
276 -
    let mut urls = Vec::new();
191 +
/// Parse an OPML document into outline entries, carrying the parent `<outline>` title
192 +
/// as a category when the parent has no `xmlUrl`.
193 +
pub fn parse_opml(content: &str) -> Vec<OpmlEntry> {
194 +
    let mut entries = Vec::new();
277 195
    let mut reader = quick_xml::Reader::from_str(content);
196 +
    let mut category_stack: Vec<String> = Vec::new();
278 197
279 198
    loop {
280 199
        match reader.read_event() {
281 -
            Ok(quick_xml::events::Event::Empty(ref e))
282 -
            | Ok(quick_xml::events::Event::Start(ref e)) => {
283 -
                if e.name().as_ref() == b"outline" {
284 -
                    for attr in e.attributes().flatten() {
285 -
                        if attr.key.as_ref() == b"xmlUrl" {
286 -
                            if let Ok(val) = attr.decode_and_unescape_value(reader.decoder()) {
287 -
                                let url = val.to_string();
288 -
                                if !url.is_empty() {
289 -
                                    urls.push(url);
290 -
                                }
291 -
                            }
292 -
                        }
200 +
            Ok(Event::Start(ref e)) if e.name().as_ref() == b"outline" => {
201 +
                let mut xml_url: Option<String> = None;
202 +
                let mut title: Option<String> = None;
203 +
                let mut html_url: Option<String> = None;
204 +
                for attr in e.attributes().flatten() {
205 +
                    let val = attr
206 +
                        .decode_and_unescape_value(reader.decoder())
207 +
                        .ok()
208 +
                        .map(|v| v.to_string());
209 +
                    match attr.key.as_ref() {
210 +
                        b"xmlUrl" => xml_url = val.filter(|v| !v.is_empty()),
211 +
                        b"title" => title = val,
212 +
                        b"text" if title.is_none() => title = val,
213 +
                        b"htmlUrl" => html_url = val,
214 +
                        _ => {}
293 215
                    }
294 216
                }
217 +
218 +
                if let Some(xml) = xml_url {
219 +
                    entries.push(OpmlEntry {
220 +
                        xml_url: xml,
221 +
                        title,
222 +
                        html_url,
223 +
                        category: category_stack.last().cloned(),
224 +
                    });
225 +
                    category_stack.push(String::new()); // balance Close event
226 +
                } else {
227 +
                    category_stack.push(title.unwrap_or_default());
228 +
                }
295 229
            }
296 -
            Ok(quick_xml::events::Event::Eof) => break,
230 +
            Ok(Event::Empty(ref e)) if e.name().as_ref() == b"outline" => {
231 +
                let mut xml_url: Option<String> = None;
232 +
                let mut title: Option<String> = None;
233 +
                let mut html_url: Option<String> = None;
234 +
                for attr in e.attributes().flatten() {
235 +
                    let val = attr
236 +
                        .decode_and_unescape_value(reader.decoder())
237 +
                        .ok()
238 +
                        .map(|v| v.to_string());
239 +
                    match attr.key.as_ref() {
240 +
                        b"xmlUrl" => xml_url = val.filter(|v| !v.is_empty()),
241 +
                        b"title" => title = val,
242 +
                        b"text" if title.is_none() => title = val,
243 +
                        b"htmlUrl" => html_url = val,
244 +
                        _ => {}
245 +
                    }
246 +
                }
247 +
                if let Some(xml) = xml_url {
248 +
                    entries.push(OpmlEntry {
249 +
                        xml_url: xml,
250 +
                        title,
251 +
                        html_url,
252 +
                        category: category_stack.last().cloned().filter(|c| !c.is_empty()),
253 +
                    });
254 +
                }
255 +
            }
256 +
            Ok(Event::End(ref e)) if e.name().as_ref() == b"outline" => {
257 +
                category_stack.pop();
258 +
            }
259 +
            Ok(Event::Eof) => break,
297 260
            Err(e) => {
298 -
                eprintln!("Error parsing OPML: {e}");
261 +
                tracing::warn!("OPML parse error: {e}");
299 262
                break;
300 263
            }
301 264
            _ => {}
302 265
        }
303 266
    }
304 267
305 -
    urls
306 -
}
307 -
308 -
pub async fn fetch_freshrss_items(config: &FreshRSSConfig) -> Result<Vec<FeedItem>, String> {
309 -
    FreshRSSClient::new(config).await?.fetch_items().await
310 -
}
311 -
312 -
pub async fn fetch_freshrss_subscriptions(
313 -
    config: &FreshRSSConfig,
314 -
) -> Result<SubscriptionList, String> {
315 -
    FreshRSSClient::new(config).await?.fetch_subscriptions().await
316 -
}
317 -
318 -
pub async fn add_freshrss_subscription(
319 -
    config: &FreshRSSConfig,
320 -
    feed_url: &str,
321 -
) -> Result<String, String> {
322 -
    FreshRSSClient::new(config).await?.add_subscription(feed_url).await
268 +
    entries
323 269
}
324 270
325 271
pub async fn discover_feeds(base_url: &str) -> Result<Vec<String>, String> {
326 272
    let parsed = Url::parse(base_url).map_err(|e| format!("Invalid URL: {e}"))?;
327 273
    let client = build_client();
328 -
329 274
    let mut feeds = Vec::new();
330 275
331 -
    // Strategy A: parse HTML for <link rel="alternate"> tags
332 276
    if let Ok(response) = client.get(base_url).send().await {
333 277
        if let Ok(body) = response.text().await {
334 278
            let document = Html::parse_document(&body);
335 279
            let selector = Selector::parse(r#"link[rel="alternate"]"#).unwrap();
336 -
337 280
            for element in document.select(&selector) {
338 281
                let type_attr = element.attr("type").unwrap_or_default();
339 282
                if type_attr.contains("rss")
354 297
        }
355 298
    }
356 299
357 -
    // Strategy B: probe common feed paths
358 300
    if feeds.is_empty() {
359 301
        let common_paths = [
360 302
            "/feed",
367 309
            "/blog/feed",
368 310
            "/blog/rss",
369 311
        ];
370 -
371 312
        let mut handles = Vec::new();
372 313
        for path in common_paths {
373 314
            let probe_url = match parsed.join(path) {
389 330
                None
390 331
            }));
391 332
        }
392 -
393 -
        for handle in handles {
394 -
            if let Ok(Some(url)) = handle.await {
333 +
        for h in handles {
334 +
            if let Ok(Some(url)) = h.await {
395 335
                if !feeds.contains(&url) {
396 336
                    feeds.push(url);
397 337
                }
411 351
    use super::*;
412 352
413 353
    #[test]
414 -
    fn parse_opml_extracts_xml_urls() {
354 +
    fn parse_opml_flat_outlines() {
415 355
        let opml = r#"<?xml version="1.0" encoding="UTF-8"?>
416 -
<opml version="2.0">
417 -
  <body>
356 +
<opml version="2.0"><body>
418 357
    <outline type="rss" text="Blog A" xmlUrl="https://a.com/feed" />
419 358
    <outline type="rss" text="Blog B" xmlUrl="https://b.com/rss" />
420 -
  </body>
421 -
</opml>"#;
422 -
        let urls = parse_opml(opml);
423 -
        assert_eq!(urls, vec!["https://a.com/feed", "https://b.com/rss"]);
359 +
</body></opml>"#;
360 +
        let entries = parse_opml(opml);
361 +
        assert_eq!(entries.len(), 2);
362 +
        assert_eq!(entries[0].xml_url, "https://a.com/feed");
363 +
        assert_eq!(entries[0].title.as_deref(), Some("Blog A"));
364 +
        assert!(entries[0].category.is_none());
424 365
    }
425 366
426 367
    #[test]
427 -
    fn parse_opml_empty_document() {
368 +
    fn parse_opml_empty() {
428 369
        let opml = r#"<?xml version="1.0"?><opml><body></body></opml>"#;
429 370
        assert!(parse_opml(opml).is_empty());
430 371
    }
431 372
432 373
    #[test]
433 -
    fn parse_opml_no_xml_url_attribute() {
374 +
    fn parse_opml_no_xml_url_skipped() {
375 +
        let opml = r#"<?xml version="1.0"?>
376 +
<opml><body><outline type="rss" text="No URL" htmlUrl="https://example.com" /></body></opml>"#;
377 +
        assert!(parse_opml(opml).is_empty());
378 +
    }
379 +
380 +
    #[test]
381 +
    fn parse_opml_nested_carries_category() {
434 382
        let opml = r#"<?xml version="1.0"?>
435 383
<opml><body>
436 -
  <outline type="rss" text="No URL" htmlUrl="https://example.com" />
384 +
  <outline text="Tech">
385 +
    <outline type="rss" text="Inner" xmlUrl="https://inner.com/feed" />
386 +
  </outline>
437 387
</body></opml>"#;
438 -
        assert!(parse_opml(opml).is_empty());
388 +
        let entries = parse_opml(opml);
389 +
        assert_eq!(entries.len(), 1);
390 +
        assert_eq!(entries[0].category.as_deref(), Some("Tech"));
439 391
    }
440 392
441 393
    #[test]
442 -
    fn parse_opml_nested_outlines() {
394 +
    fn parse_opml_deeply_nested() {
443 395
        let opml = r#"<?xml version="1.0"?>
444 396
<opml><body>
445 -
  <outline text="Category">
446 -
    <outline type="rss" text="Nested" xmlUrl="https://nested.com/feed" />
397 +
  <outline text="Root">
398 +
    <outline text="Tech">
399 +
      <outline type="rss" text="A" xmlUrl="https://a.com/feed" />
400 +
    </outline>
401 +
    <outline type="rss" text="B" xmlUrl="https://b.com/feed" />
447 402
  </outline>
448 403
</body></opml>"#;
449 -
        let urls = parse_opml(opml);
450 -
        assert_eq!(urls, vec!["https://nested.com/feed"]);
404 +
        let entries = parse_opml(opml);
405 +
        assert_eq!(entries.len(), 2);
406 +
        assert_eq!(entries[0].xml_url, "https://a.com/feed");
407 +
        assert_eq!(entries[0].category.as_deref(), Some("Tech"));
408 +
        assert_eq!(entries[1].xml_url, "https://b.com/feed");
409 +
        assert_eq!(entries[1].category.as_deref(), Some("Root"));
451 410
    }
452 411
453 412
    #[test]
457 416
  <outline type="rss" text="Empty" xmlUrl="" />
458 417
  <outline type="rss" text="Valid" xmlUrl="https://valid.com/feed" />
459 418
</body></opml>"#;
460 -
        let urls = parse_opml(opml);
461 -
        assert_eq!(urls, vec!["https://valid.com/feed"]);
462 -
    }
463 -
}
464 -
465 -
pub async fn get_feed_items(
466 -
    url_query: Option<&str>,
467 -
    freshrss_config: Option<&FreshRSSConfig>,
468 -
) -> Result<(Vec<FeedItem>, Option<Vec<String>>), String> {
469 -
    if let Some(query) = url_query {
470 -
        let urls: Vec<String> = query
471 -
            .split(',')
472 -
            .map(|u| u.trim().to_string())
473 -
            .filter(|u| !u.is_empty())
474 -
            .collect();
475 -
476 -
        if !urls.is_empty() {
477 -
            let items = parse_urls(&urls).await;
478 -
            return Ok((items, Some(urls)));
479 -
        }
480 -
    }
481 -
482 -
    if let Ok(content) = tokio::fs::read_to_string("feeds.opml").await {
483 -
        let urls = parse_opml(&content);
484 -
        if !urls.is_empty() {
485 -
            let items = parse_urls(&urls).await;
486 -
            return Ok((items, None));
487 -
        }
419 +
        let entries = parse_opml(opml);
420 +
        assert_eq!(entries.len(), 1);
421 +
        assert_eq!(entries[0].xml_url, "https://valid.com/feed");
488 422
    }
489 -
490 -
    if let Some(config) = freshrss_config {
491 -
        let items = fetch_freshrss_items(config).await?;
492 -
        return Ok((items, None));
493 -
    }
494 -
495 -
    if let Ok(default_feed) = std::env::var("DEFAULT_FEED") {
496 -
        let urls: Vec<String> = default_feed
497 -
            .split(',')
498 -
            .map(|u| u.trim().to_string())
499 -
            .filter(|u| !u.is_empty())
500 -
            .collect();
501 -
502 -
        if !urls.is_empty() {
503 -
            let items = parse_urls(&urls).await;
504 -
            return Ok((items, Some(urls)));
505 -
        }
506 -
    }
507 -
508 -
    Err("No feed source configured. Set FRESHRSS_URL/FRESHRSS_USERNAME/FRESHRSS_PASSWORD or DEFAULT_FEED.".to_string())
509 423
}
apps/feeds/src/main.rs +412 −206
1 +
mod api;
1 2
mod auth;
2 3
mod feeds;
3 4
mod models;
5 +
mod poller;
6 +
7 +
use std::collections::HashMap;
8 +
use std::sync::{Arc, Mutex};
4 9
10 +
use andromeda_db::{
11 +
    feeds as fdb,
12 +
    session::{SESSION_SCHEMA, prune_expired_sessions},
13 +
    Db,
14 +
};
5 15
use askama::Template;
6 16
use axum::{
7 -
    extract::{Query, State},
17 +
    extract::{Multipart, Path, Query, State},
8 18
    http::{header, HeaderMap, StatusCode},
9 19
    response::{Html, IntoResponse, Json, Redirect, Response},
10 -
    routing::{get, post},
20 +
    routing::{delete, get, post},
11 21
    Form, Router,
12 22
};
13 23
use chrono::DateTime;
14 24
use rust_embed::Embed;
25 +
use rusqlite::Connection;
15 26
use serde::Deserialize;
16 -
use std::collections::HashMap;
17 -
use std::sync::Arc;
27 +
28 +
use crate::poller::POLL_INTERVAL_KEY;
18 29
19 30
#[derive(Embed)]
20 31
#[folder = "static/"]
21 32
struct Static;
22 33
23 34
pub struct AppState {
24 -
    sessions: auth::SessionStore,
25 -
    admin_password: Option<String>,
26 -
    cookie_secure: bool,
27 -
    base_url: String,
28 -
    freshrss_config: Option<feeds::FreshRSSConfig>,
35 +
    pub db: Db,
36 +
    pub admin_password: Option<String>,
37 +
    pub api_key: Option<String>,
38 +
    pub cookie_secure: bool,
39 +
    pub base_url: String,
40 +
    pub default_poll_minutes: u64,
41 +
    pub item_cap: usize,
29 42
}
30 43
31 44
struct TemplateFeedItem {
53 66
#[derive(Template)]
54 67
#[template(path = "admin.html")]
55 68
struct AdminTemplate {
56 -
    freshrss_configured: bool,
57 69
    success: Option<String>,
58 70
    error: Option<String>,
59 -
    subscriptions: Option<Vec<models::Subscription>>,
71 +
    subscriptions: Vec<AdminSubRow>,
72 +
    categories: Vec<fdb::Category>,
73 +
    poll_interval_minutes: u64,
74 +
    item_cap: usize,
75 +
    api_key_configured: bool,
76 +
}
77 +
78 +
struct AdminSubRow {
79 +
    id: i64,
80 +
    title: String,
81 +
    feed_url: String,
82 +
    category_name: Option<String>,
83 +
    last_fetched_at: Option<String>,
84 +
    last_error: Option<String>,
60 85
}
61 86
62 87
fn format_date(timestamp: i64) -> String {
64 89
        .map(|dt| dt.format("%b %-d, %Y").to_string())
65 90
        .unwrap_or_default()
66 91
}
92 +
93 +
// ── Public pages ──────────────────────────────────────────────────────
67 94
68 95
async fn index_handler(
69 96
    State(state): State<Arc<AppState>>,
70 97
    Query(params): Query<HashMap<String, String>>,
71 -
) -> impl IntoResponse {
72 -
    let url_query = params
73 -
        .get("url")
74 -
        .or_else(|| params.get("urls"))
75 -
        .map(|s| s.as_str());
98 +
) -> Response {
99 +
    let url_query = params.get("url").or_else(|| params.get("urls"));
76 100
77 -
    let template = match feeds::get_feed_items(url_query, state.freshrss_config.as_ref()).await {
78 -
        Ok((items, feed_urls)) => {
79 -
            let template_items: Vec<TemplateFeedItem> = items
101 +
    let (items, feed_urls, error) = if let Some(query) = url_query {
102 +
        let urls: Vec<String> = query
103 +
            .split(',')
104 +
            .map(|u| u.trim().to_string())
105 +
            .filter(|u| !u.is_empty())
106 +
            .collect();
107 +
        if urls.is_empty() {
108 +
            (Vec::new(), None, Some("No URLs provided".to_string()))
109 +
        } else {
110 +
            let items = feeds::preview_urls(&urls)
111 +
                .await
80 112
                .into_iter()
81 113
                .map(|item| TemplateFeedItem {
82 114
                    title: item.title,
85 117
                    formatted_date: format_date(item.published),
86 118
                })
87 119
                .collect();
88 -
89 -
            IndexTemplate {
90 -
                base_url: state.base_url.clone(),
91 -
                items: template_items,
92 -
                feed_urls,
93 -
                error: None,
120 +
            (items, Some(urls), None)
121 +
        }
122 +
    } else {
123 +
        match fdb::list_items(
124 +
            &state.db,
125 +
            &fdb::ListItemsFilter {
126 +
                limit: Some(100),
127 +
                ..Default::default()
128 +
            },
129 +
        ) {
130 +
            Ok(items) => {
131 +
                let rows = items
132 +
                    .into_iter()
133 +
                    .map(|i| TemplateFeedItem {
134 +
                        title: i.title,
135 +
                        link: i.link,
136 +
                        author: match i.author {
137 +
                            Some(a) if !a.is_empty() => format!("{} - {}", i.feed_title, a),
138 +
                            _ => i.feed_title,
139 +
                        },
140 +
                        formatted_date: format_date(i.published_at),
141 +
                    })
142 +
                    .collect();
143 +
                (rows, None, None)
94 144
            }
95 -
        }
96 -
        Err(e) => {
97 -
            eprintln!("Error fetching feeds: {e}");
98 -
            IndexTemplate {
99 -
                base_url: state.base_url.clone(),
100 -
                items: Vec::new(),
101 -
                feed_urls: None,
102 -
                error: Some("Error loading feeds. Please try again later.".to_string()),
145 +
            Err(e) => {
146 +
                tracing::error!("index query failed: {e}");
147 +
                (
148 +
                    Vec::new(),
149 +
                    None,
150 +
                    Some("Error loading feeds. Please try again later.".to_string()),
151 +
                )
103 152
            }
104 153
        }
105 154
    };
106 155
107 -
    Html(template.render().unwrap())
156 +
    Html(
157 +
        IndexTemplate {
158 +
            base_url: state.base_url.clone(),
159 +
            items,
160 +
            feed_urls,
161 +
            error,
162 +
        }
163 +
        .render()
164 +
        .unwrap(),
165 +
    )
166 +
    .into_response()
108 167
}
109 168
110 -
async fn feeds_handler(
111 -
    State(state): State<Arc<AppState>>,
112 -
    Query(params): Query<HashMap<String, String>>,
113 -
) -> Result<Response, StatusCode> {
114 -
    let format = params
115 -
        .get("format")
116 -
        .map(|s| s.as_str())
117 -
        .unwrap_or("json");
169 +
/// Export current subscriptions as OPML.
170 +
async fn feeds_opml_handler(State(state): State<Arc<AppState>>) -> Response {
171 +
    let subs = match fdb::list_subscriptions(&state.db) {
172 +
        Ok(s) => s,
173 +
        Err(e) => {
174 +
            tracing::error!("opml export failed: {e}");
175 +
            return StatusCode::INTERNAL_SERVER_ERROR.into_response();
176 +
        }
177 +
    };
178 +
    let cats: HashMap<i64, String> = fdb::list_categories(&state.db)
179 +
        .unwrap_or_default()
180 +
        .into_iter()
181 +
        .map(|c| (c.id, c.name))
182 +
        .collect();
118 183
119 -
    let config = state
120 -
        .freshrss_config
121 -
        .as_ref()
122 -
        .ok_or(StatusCode::INTERNAL_SERVER_ERROR)?;
184 +
    let now = chrono::Utc::now().to_rfc2822();
185 +
    let mut by_cat: HashMap<String, Vec<&fdb::Subscription>> = HashMap::new();
186 +
    for sub in &subs {
187 +
        let key = sub
188 +
            .category_id
189 +
            .and_then(|id| cats.get(&id).cloned())
190 +
            .unwrap_or_default();
191 +
        by_cat.entry(key).or_default().push(sub);
192 +
    }
123 193
124 -
    let data = feeds::fetch_freshrss_subscriptions(config)
125 -
        .await
126 -
        .map_err(|e| {
127 -
            eprintln!("Failed to fetch subscriptions: {e}");
128 -
            StatusCode::INTERNAL_SERVER_ERROR
129 -
        })?;
194 +
    let mut opml = format!(
195 +
        "<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n<opml version=\"2.0\">\n  <head>\n    <title>Feeds</title>\n    <dateCreated>{now}</dateCreated>\n  </head>\n  <body>\n"
196 +
    );
130 197
131 -
    match format {
132 -
        "json" => Ok(Json(serde_json::json!(data)).into_response()),
133 -
        "opml" => {
134 -
            let now = chrono::Utc::now().to_rfc2822();
135 -
            let subscriptions = data.subscriptions.unwrap_or_default();
136 -
137 -
            let mut opml = format!(
138 -
                r#"<?xml version="1.0" encoding="UTF-8"?>
139 -
<opml version="2.0">
140 -
  <head>
141 -
    <title>Steve's Feeds</title>
142 -
    <dateCreated>{now}</dateCreated>
143 -
  </head>
144 -
  <body>
145 -
"#
146 -
            );
147 -
148 -
            for feed in &subscriptions {
149 -
                opml.push_str(&format!(
150 -
                    "    <outline type=\"rss\" text=\"{}\" title=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\" />\n",
151 -
                    escape_xml(&feed.title),
152 -
                    escape_xml(&feed.title),
153 -
                    escape_xml(&feed.url),
154 -
                    escape_xml(feed.html_url.as_deref().unwrap_or("")),
155 -
                ));
156 -
            }
157 -
158 -
            opml.push_str("  </body>\n</opml>");
159 -
160 -
            Ok((
161 -
                [
162 -
                    (header::CONTENT_TYPE, "application/xml"),
163 -
                    (
164 -
                        header::CONTENT_DISPOSITION,
165 -
                        "attachment; filename=\"feeds.opml\"",
166 -
                    ),
167 -
                ],
168 -
                opml,
169 -
            )
170 -
                .into_response())
198 +
    let mut keys: Vec<&String> = by_cat.keys().collect();
199 +
    keys.sort();
200 +
    for key in keys {
201 +
        let subs = &by_cat[key];
202 +
        let indent = if key.is_empty() { "    " } else { "      " };
203 +
        if !key.is_empty() {
204 +
            opml.push_str(&format!(
205 +
                "    <outline text=\"{}\" title=\"{}\">\n",
206 +
                escape_xml(key),
207 +
                escape_xml(key)
208 +
            ));
171 209
        }
172 -
        _ => Ok((
173 -
            StatusCode::BAD_REQUEST,
174 -
            Json(serde_json::json!({
175 -
                "error": "Invalid format. Use ?format=json or ?format=opml"
176 -
            })),
177 -
        )
178 -
            .into_response()),
210 +
        for sub in subs {
211 +
            opml.push_str(&format!(
212 +
                "{indent}<outline type=\"rss\" text=\"{}\" title=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\" />\n",
213 +
                escape_xml(&sub.title),
214 +
                escape_xml(&sub.title),
215 +
                escape_xml(&sub.feed_url),
216 +
                escape_xml(sub.site_url.as_deref().unwrap_or("")),
217 +
            ));
218 +
        }
219 +
        if !key.is_empty() {
220 +
            opml.push_str("    </outline>\n");
221 +
        }
179 222
    }
223 +
224 +
    opml.push_str("  </body>\n</opml>");
225 +
226 +
    (
227 +
        [
228 +
            (header::CONTENT_TYPE, "application/xml"),
229 +
            (
230 +
                header::CONTENT_DISPOSITION,
231 +
                "attachment; filename=\"feeds.opml\"",
232 +
            ),
233 +
        ],
234 +
        opml,
235 +
    )
236 +
        .into_response()
180 237
}
181 238
182 239
fn escape_xml(s: &str) -> String {
187 244
        .replace('\'', "&apos;")
188 245
}
189 246
190 -
#[cfg(test)]
191 -
mod tests {
192 -
    use super::*;
193 -
194 -
    #[test]
195 -
    fn escape_xml_ampersand() {
196 -
        assert_eq!(escape_xml("A&B"), "A&amp;B");
197 -
    }
198 -
199 -
    #[test]
200 -
    fn escape_xml_less_than() {
201 -
        assert_eq!(escape_xml("a<b"), "a&lt;b");
202 -
    }
203 -
204 -
    #[test]
205 -
    fn escape_xml_greater_than() {
206 -
        assert_eq!(escape_xml("a>b"), "a&gt;b");
207 -
    }
208 -
209 -
    #[test]
210 -
    fn escape_xml_quote() {
211 -
        assert_eq!(escape_xml(r#"a"b"#), "a&quot;b");
212 -
    }
213 -
214 -
    #[test]
215 -
    fn escape_xml_apostrophe() {
216 -
        assert_eq!(escape_xml("a'b"), "a&apos;b");
217 -
    }
218 -
219 -
    #[test]
220 -
    fn escape_xml_all_special() {
221 -
        assert_eq!(
222 -
            escape_xml(r#"<a href="x">&'test'</a>"#),
223 -
            "&lt;a href=&quot;x&quot;&gt;&amp;&apos;test&apos;&lt;/a&gt;"
224 -
        );
225 -
    }
226 -
227 -
    #[test]
228 -
    fn escape_xml_no_special_chars() {
229 -
        assert_eq!(escape_xml("hello world"), "hello world");
230 -
    }
231 -
232 -
    #[test]
233 -
    fn escape_xml_empty() {
234 -
        assert_eq!(escape_xml(""), "");
235 -
    }
236 -
237 -
    #[test]
238 -
    fn format_date_valid_timestamp() {
239 -
        // 2024-01-15 00:00:00 UTC
240 -
        assert_eq!(format_date(1705276800), "Jan 15, 2024");
241 -
    }
242 -
243 -
    #[test]
244 -
    fn format_date_zero() {
245 -
        assert_eq!(format_date(0), "Jan 1, 1970");
246 -
    }
247 -
}
248 -
249 247
async fn static_handler(axum::extract::Path(path): axum::extract::Path<String>) -> Response {
250 248
    match Static::get(&path) {
251 249
        Some(file) => {
252 250
            let mime = mime_guess::from_path(&path).first_or_octet_stream();
253 -
            (
254 -
                [(header::CONTENT_TYPE, mime.as_ref())],
255 -
                file.data.to_vec(),
256 -
            )
257 -
                .into_response()
251 +
            ([(header::CONTENT_TYPE, mime.as_ref())], file.data.to_vec()).into_response()
258 252
        }
259 253
        None => StatusCode::NOT_FOUND.into_response(),
260 254
    }
261 255
}
262 256
263 -
// --- Admin routes ---
257 +
// ── Admin UI ──────────────────────────────────────────────────────────
264 258
265 259
#[derive(Deserialize, Default)]
266 260
struct FlashQuery {
276 270
#[derive(Deserialize)]
277 271
struct AddFeedForm {
278 272
    feed_url: String,
273 +
    category_name: Option<String>,
279 274
}
280 275
281 276
#[derive(Deserialize)]
283 278
    base_url: String,
284 279
}
285 280
286 -
async fn login_get_handler(Query(q): Query<FlashQuery>) -> impl IntoResponse {
287 -
    Html(LoginTemplate { error: q.error }.render().unwrap())
281 +
#[derive(Deserialize)]
282 +
struct AddCategoryForm {
283 +
    name: String,
284 +
}
285 +
286 +
#[derive(Deserialize)]
287 +
struct UpdateSubCategoryForm {
288 +
    category_name: Option<String>,
289 +
}
290 +
291 +
#[derive(Deserialize)]
292 +
struct UpdateSettingsForm {
293 +
    poll_interval_minutes: u64,
294 +
}
295 +
296 +
async fn login_get_handler(Query(q): Query<FlashQuery>) -> Response {
297 +
    Html(LoginTemplate { error: q.error }.render().unwrap()).into_response()
288 298
}
289 299
290 300
async fn login_post_handler(
297 307
            return Redirect::to("/admin/login?error=No+admin+password+configured").into_response();
298 308
        }
299 309
    };
300 -
301 310
    if !auth::verify_password(&form.password, admin_password) {
302 311
        return Redirect::to("/admin/login?error=Invalid+password").into_response();
303 312
    }
304 313
305 314
    let token = auth::generate_session_token();
306 -
    auth::create_session(&state.sessions, &token);
307 -
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
315 +
    if let Err(e) = auth::create_session(&state.db, &token) {
316 +
        tracing::error!("failed to create session: {e}");
317 +
        return Redirect::to("/admin/login?error=Session+error").into_response();
318 +
    }
319 +
    let _ = prune_expired_sessions(&state.db);
308 320
321 +
    let cookie = auth::build_session_cookie(&token, state.cookie_secure);
309 322
    let mut resp = Redirect::to("/admin").into_response();
310 323
    resp.headers_mut()
311 324
        .insert(header::SET_COOKIE, cookie.parse().unwrap());
312 325
    resp
313 326
}
314 327
315 -
async fn logout_handler(
316 -
    State(state): State<Arc<AppState>>,
317 -
    headers: HeaderMap,
318 -
) -> Response {
328 +
async fn logout_handler(State(state): State<Arc<AppState>>, headers: HeaderMap) -> Response {
319 329
    if let Some(token) = auth::extract_session_cookie(&headers) {
320 -
        auth::delete_session(&state.sessions, &token);
330 +
        auth::delete_session(&state.db, &token);
321 331
    }
322 332
    let mut resp = Redirect::to("/admin/login").into_response();
323 333
    resp.headers_mut().insert(
332 342
    State(state): State<Arc<AppState>>,
333 343
    Query(q): Query<FlashQuery>,
334 344
) -> Response {
335 -
    let freshrss_configured = state.freshrss_config.is_some();
345 +
    let subs = fdb::list_subscriptions(&state.db).unwrap_or_default();
346 +
    let cats = fdb::list_categories(&state.db).unwrap_or_default();
347 +
    let cat_map: HashMap<i64, String> =
348 +
        cats.iter().map(|c| (c.id, c.name.clone())).collect();
349 +
350 +
    let subscriptions = subs
351 +
        .into_iter()
352 +
        .map(|s| AdminSubRow {
353 +
            id: s.id,
354 +
            title: s.title,
355 +
            feed_url: s.feed_url,
356 +
            category_name: s.category_id.and_then(|id| cat_map.get(&id).cloned()),
357 +
            last_fetched_at: s.last_fetched_at,
358 +
            last_error: s.last_error,
359 +
        })
360 +
        .collect();
336 361
337 -
    let subscriptions = if let Some(config) = &state.freshrss_config {
338 -
        feeds::fetch_freshrss_subscriptions(config)
339 -
            .await
340 -
            .ok()
341 -
            .and_then(|list| list.subscriptions)
342 -
    } else {
343 -
        None
344 -
    };
362 +
    let poll_interval_minutes = fdb::get_setting(&state.db, POLL_INTERVAL_KEY)
363 +
        .ok()
364 +
        .flatten()
365 +
        .and_then(|v| v.parse::<u64>().ok())
366 +
        .unwrap_or(state.default_poll_minutes);
345 367
346 368
    Html(
347 369
        AdminTemplate {
348 -
            freshrss_configured,
349 370
            success: q.success,
350 371
            error: q.error,
351 372
            subscriptions,
373 +
            categories: cats,
374 +
            poll_interval_minutes,
375 +
            item_cap: state.item_cap,
376 +
            api_key_configured: state.api_key.is_some(),
352 377
        }
353 378
        .render()
354 379
        .unwrap(),
363 388
    match feeds::discover_feeds(&form.base_url).await {
364 389
        Ok(urls) => Json(serde_json::json!(urls)).into_response(),
365 390
        Err(e) => (
366 -
            axum::http::StatusCode::BAD_REQUEST,
391 +
            StatusCode::BAD_REQUEST,
367 392
            Json(serde_json::json!({ "error": e })),
368 393
        )
369 394
            .into_response(),
375 400
    State(state): State<Arc<AppState>>,
376 401
    Form(form): Form<AddFeedForm>,
377 402
) -> Response {
378 -
    let config = match &state.freshrss_config {
379 -
        Some(c) => c,
380 -
        None => {
381 -
            return Redirect::to("/admin?error=FreshRSS+not+configured").into_response();
403 +
    let body = api::CreateSubscriptionBody {
404 +
        feed_url: form.feed_url,
405 +
        title: None,
406 +
        category_id: None,
407 +
        category_name: form.category_name.filter(|s| !s.trim().is_empty()),
408 +
    };
409 +
    let resp = api::add_subscription(&state, &body).await;
410 +
    let status = resp.status();
411 +
    if status.is_success() {
412 +
        Redirect::to("/admin?success=Feed+added").into_response()
413 +
    } else if status == StatusCode::CONFLICT {
414 +
        Redirect::to("/admin?error=Already+subscribed").into_response()
415 +
    } else {
416 +
        Redirect::to("/admin?error=Failed+to+add+feed").into_response()
417 +
    }
418 +
}
419 +
420 +
async fn delete_feed_handler(
421 +
    _session: auth::AuthSession,
422 +
    State(state): State<Arc<AppState>>,
423 +
    Path(id): Path<i64>,
424 +
) -> Response {
425 +
    match fdb::delete_subscription(&state.db, id) {
426 +
        Ok(true) => Redirect::to("/admin?success=Feed+removed").into_response(),
427 +
        _ => Redirect::to("/admin?error=Failed+to+remove").into_response(),
428 +
    }
429 +
}
430 +
431 +
async fn update_sub_category_handler(
432 +
    _session: auth::AuthSession,
433 +
    State(state): State<Arc<AppState>>,
434 +
    Path(id): Path<i64>,
435 +
    Form(form): Form<UpdateSubCategoryForm>,
436 +
) -> Response {
437 +
    let name = form.category_name.as_deref().map(str::trim).unwrap_or("");
438 +
    let category_id = if name.is_empty() {
439 +
        None
440 +
    } else {
441 +
        fdb::get_or_create_category(&state.db, name)
442 +
            .ok()
443 +
            .map(|c| c.id)
444 +
    };
445 +
    let _ = fdb::update_subscription_category(&state.db, id, category_id);
446 +
    Redirect::to("/admin?success=Category+updated").into_response()
447 +
}
448 +
449 +
async fn add_category_handler(
450 +
    _session: auth::AuthSession,
451 +
    State(state): State<Arc<AppState>>,
452 +
    Form(form): Form<AddCategoryForm>,
453 +
) -> Response {
454 +
    let name = form.name.trim();
455 +
    if name.is_empty() {
456 +
        return Redirect::to("/admin?error=Name+required").into_response();
457 +
    }
458 +
    match fdb::get_or_create_category(&state.db, name) {
459 +
        Ok(_) => Redirect::to("/admin?success=Category+added").into_response(),
460 +
        Err(_) => Redirect::to("/admin?error=Failed+to+add+category").into_response(),
461 +
    }
462 +
}
463 +
464 +
async fn delete_category_handler(
465 +
    _session: auth::AuthSession,
466 +
    State(state): State<Arc<AppState>>,
467 +
    Path(id): Path<i64>,
468 +
) -> Response {
469 +
    let _ = fdb::delete_category(&state.db, id);
470 +
    Redirect::to("/admin?success=Category+removed").into_response()
471 +
}
472 +
473 +
async fn import_opml_handler(
474 +
    _session: auth::AuthSession,
475 +
    State(state): State<Arc<AppState>>,
476 +
    mut multipart: Multipart,
477 +
) -> Response {
478 +
    let mut content: Option<String> = None;
479 +
    while let Ok(Some(field)) = multipart.next_field().await {
480 +
        if field.name() == Some("file") {
481 +
            if let Ok(s) = field.text().await {
482 +
                content = Some(s);
483 +
            }
382 484
        }
485 +
    }
486 +
    let Some(content) = content else {
487 +
        return Redirect::to("/admin?error=No+file+uploaded").into_response();
383 488
    };
489 +
    let summary = api::import_opml_str(state, &content).await;
490 +
    let msg = format!(
491 +
        "Imported+{}%2C+skipped+{}",
492 +
        summary.imported, summary.skipped
493 +
    );
494 +
    Redirect::to(&format!("/admin?success={msg}")).into_response()
495 +
}
384 496
385 -
    match feeds::add_freshrss_subscription(config, &form.feed_url).await {
386 -
        Ok(_) => Redirect::to("/admin?success=Feed+added+successfully").into_response(),
387 -
        Err(e) => {
388 -
            eprintln!("Failed to add feed: {e}");
389 -
            let encoded = urlencoding::encode(&e);
390 -
            Redirect::to(&format!("/admin?error={encoded}")).into_response()
391 -
        }
497 +
async fn update_settings_handler(
498 +
    _session: auth::AuthSession,
499 +
    State(state): State<Arc<AppState>>,
500 +
    Form(form): Form<UpdateSettingsForm>,
501 +
) -> Response {
502 +
    if !(1..=1440).contains(&form.poll_interval_minutes) {
503 +
        return Redirect::to("/admin?error=Interval+must+be+1-1440").into_response();
392 504
    }
505 +
    let _ = fdb::set_setting(
506 +
        &state.db,
507 +
        POLL_INTERVAL_KEY,
508 +
        &form.poll_interval_minutes.to_string(),
509 +
    );
510 +
    Redirect::to("/admin?success=Settings+saved").into_response()
393 511
}
394 512
513 +
// ── main ──────────────────────────────────────────────────────────────
514 +
395 515
#[tokio::main]
396 516
async fn main() {
397 517
    dotenvy::dotenv().ok();
518 +
    tracing_subscriber::fmt()
519 +
        .with_env_filter(
520 +
            tracing_subscriber::EnvFilter::try_from_default_env()
521 +
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info,feeds=info")),
522 +
        )
523 +
        .init();
524 +
525 +
    let db_path = std::env::var("DB_PATH").unwrap_or_else(|_| "feeds.sqlite".to_string());
526 +
    let conn = Connection::open(&db_path).expect("open sqlite");
527 +
    conn.execute_batch(SESSION_SCHEMA).expect("session schema");
528 +
    conn.execute_batch(fdb::FEEDS_SCHEMA).expect("feeds schema");
529 +
    let db: Db = Arc::new(Mutex::new(conn));
398 530
399 531
    let cookie_secure = std::env::var("COOKIE_SECURE")
400 532
        .map(|v| v.eq_ignore_ascii_case("true"))
401 533
        .unwrap_or(false);
534 +
    let base_url =
535 +
        std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
536 +
    let default_poll_minutes: u64 = std::env::var("DEFAULT_POLL_MINUTES")
537 +
        .ok()
538 +
        .and_then(|v| v.parse().ok())
539 +
        .unwrap_or(30);
540 +
    let item_cap: usize = std::env::var("ITEM_CAP_PER_FEED")
541 +
        .ok()
542 +
        .and_then(|v| v.parse().ok())
543 +
        .unwrap_or(200);
402 544
403 -
    let base_url = std::env::var("BASE_URL").unwrap_or_else(|_| "http://localhost:3000".to_string());
545 +
    // Seed poll-interval setting if missing so the admin UI shows a value.
546 +
    if fdb::get_setting(&db, POLL_INTERVAL_KEY).ok().flatten().is_none() {
547 +
        let _ = fdb::set_setting(&db, POLL_INTERVAL_KEY, &default_poll_minutes.to_string());
548 +
    }
549 +
550 +
    let api_key = std::env::var("API_KEY").ok().filter(|s| !s.is_empty());
551 +
    if api_key.is_none() {
552 +
        tracing::warn!("API_KEY is not set; /api is accessible via session cookie only");
553 +
    }
404 554
405 555
    let state = Arc::new(AppState {
406 -
        sessions: auth::new_session_store(),
556 +
        db,
407 557
        admin_password: std::env::var("ADMIN_PASSWORD").ok(),
558 +
        api_key,
408 559
        cookie_secure,
409 560
        base_url,
410 -
        freshrss_config: feeds::FreshRSSConfig::from_env(),
561 +
        default_poll_minutes,
562 +
        item_cap,
411 563
    });
412 564
413 -
    let app = Router::new()
414 -
        .route("/", get(index_handler))
415 -
        .route("/feeds", get(feeds_handler))
565 +
    tokio::spawn(poller::run(state.clone()));
566 +
567 +
    let admin_router = Router::new()
416 568
        .route("/admin", get(admin_handler))
417 569
        .route(
418 570
            "/admin/login",
420 572
        )
421 573
        .route("/admin/logout", get(logout_handler))
422 574
        .route("/admin/add-feed", post(add_feed_handler))
423 -
        .route("/admin/discover-feeds", post(discover_feeds_handler))
575 +
        .route("/admin/feeds/{id}/delete", post(delete_feed_handler))
576 +
        .route("/admin/feeds/{id}/category", post(update_sub_category_handler))
577 +
        .route("/admin/categories", post(add_category_handler))
578 +
        .route("/admin/categories/{id}/delete", post(delete_category_handler))
579 +
        .route("/admin/import-opml", post(import_opml_handler))
580 +
        .route("/admin/settings", post(update_settings_handler))
581 +
        .route("/admin/discover-feeds", post(discover_feeds_handler));
582 +
583 +
    let api_router = Router::new()
584 +
        .route("/api/items", get(api::list_items))
585 +
        .route("/api/items/{id}/read", post(api::mark_item_read))
586 +
        .route("/api/items/{id}/unread", post(api::mark_item_unread))
587 +
        .route(
588 +
            "/api/subscriptions",
589 +
            get(api::list_subscriptions).post(api::create_subscription),
590 +
        )
591 +
        .route(
592 +
            "/api/subscriptions/{id}",
593 +
            delete(api::delete_subscription).patch(api::update_subscription),
594 +
        )
595 +
        .route(
596 +
            "/api/categories",
597 +
            get(api::list_categories).post(api::create_category),
598 +
        )
599 +
        .route("/api/categories/{id}", delete(api::delete_category))
600 +
        .route("/api/import/opml", post(api::import_opml))
601 +
        .route(
602 +
            "/api/settings",
603 +
            get(api::get_settings).put(api::update_settings),
604 +
        )
605 +
        .route("/api/discover", post(api::discover));
606 +
607 +
    let app = Router::new()
608 +
        .route("/", get(index_handler))
609 +
        .route("/feeds.opml", get(feeds_opml_handler))
424 610
        .route("/static/{*path}", get(static_handler))
611 +
        .merge(admin_router)
612 +
        .merge(api_router)
425 613
        .with_state(state);
426 614
427 615
    let host = std::env::var("HOST").unwrap_or_else(|_| "0.0.0.0".to_string());
429 617
        .ok()
430 618
        .and_then(|v| v.parse().ok())
431 619
        .unwrap_or(3000);
432 -
    let addr = format!("{}:{}", host, port);
620 +
    let addr = format!("{host}:{port}");
433 621
    let listener = tokio::net::TcpListener::bind(&addr)
434 622
        .await
435 -
        .unwrap_or_else(|_| panic!("Failed to bind to {}", addr));
623 +
        .unwrap_or_else(|_| panic!("Failed to bind to {addr}"));
436 624
437 -
    println!("Server running on http://{}:{}", host, port);
625 +
    tracing::info!("Feeds server running on http://{host}:{port}");
438 626
    axum::serve(listener, app).await.unwrap();
439 627
}
628 +
629 +
#[cfg(test)]
630 +
mod tests {
631 +
    use super::*;
632 +
633 +
    #[test]
634 +
    fn escape_xml_all_special() {
635 +
        assert_eq!(
636 +
            escape_xml(r#"<a href="x">&'test'</a>"#),
637 +
            "&lt;a href=&quot;x&quot;&gt;&amp;&apos;test&apos;&lt;/a&gt;"
638 +
        );
639 +
    }
640 +
641 +
    #[test]
642 +
    fn format_date_valid_timestamp() {
643 +
        assert_eq!(format_date(1705276800), "Jan 15, 2024");
644 +
    }
645 +
}
apps/feeds/src/models.rs +2 −52
1 1
use serde::{Deserialize, Serialize};
2 2
3 +
/// Normalized feed entry used by the index template and ad-hoc URL previews.
3 4
#[derive(Debug, Clone, Serialize, Deserialize)]
4 5
pub struct FeedItem {
5 -
    pub id: String,
6 6
    pub title: String,
7 -
    pub published: i64,
8 -
    pub author: String,
9 7
    pub link: String,
10 -
    pub origin: String,
11 -
}
12 -
13 -
#[allow(dead_code)]
14 -
#[derive(Debug, Deserialize)]
15 -
pub struct FreshRSSResponse {
16 -
    pub id: String,
17 -
    pub updated: Option<i64>,
18 -
    pub items: Vec<FreshRSSItem>,
19 -
    pub continuation: Option<String>,
20 -
}
21 -
22 -
#[allow(dead_code)]
23 -
#[derive(Debug, Deserialize)]
24 -
pub struct FreshRSSItem {
25 -
    pub id: String,
26 -
    pub title: String,
8 +
    pub author: String,
27 9
    pub published: i64,
28 -
    pub author: Option<String>,
29 -
    pub canonical: Option<Vec<FreshRSSLink>>,
30 -
    pub origin: FreshRSSOrigin,
31 -
}
32 -
33 -
#[derive(Debug, Deserialize)]
34 -
pub struct FreshRSSLink {
35 -
    pub href: String,
36 -
}
37 -
38 -
#[allow(dead_code)]
39 -
#[derive(Debug, Clone, Deserialize)]
40 -
pub struct FreshRSSOrigin {
41 -
    #[serde(rename = "streamId")]
42 -
    pub stream_id: String,
43 -
    #[serde(rename = "htmlUrl")]
44 -
    pub html_url: Option<String>,
45 -
    pub title: String,
46 -
}
47 -
48 -
#[derive(Debug, Serialize, Deserialize)]
49 -
pub struct Subscription {
50 -
    pub id: String,
51 -
    pub title: String,
52 -
    pub url: String,
53 -
    #[serde(rename = "htmlUrl", skip_serializing_if = "Option::is_none")]
54 -
    pub html_url: Option<String>,
55 -
}
56 -
57 -
#[derive(Debug, Serialize, Deserialize)]
58 -
pub struct SubscriptionList {
59 -
    pub subscriptions: Option<Vec<Subscription>>,
60 10
}
apps/feeds/src/poller.rs (added) +107 −0
1 +
use std::sync::Arc;
2 +
use std::time::Duration;
3 +
4 +
use andromeda_db::feeds as fdb;
5 +
use chrono::Utc;
6 +
7 +
use crate::feeds::{fetch_feed, FetchResult};
8 +
use crate::AppState;
9 +
10 +
pub const POLL_INTERVAL_KEY: &str = "poll_interval_minutes";
11 +
12 +
pub async fn run(state: Arc<AppState>) {
13 +
    // Stagger the first pass so startup is fast.
14 +
    tokio::time::sleep(Duration::from_secs(3)).await;
15 +
    loop {
16 +
        let minutes = poll_interval_minutes(&state);
17 +
        tracing::info!("poller pass starting (interval {minutes}m)");
18 +
        if let Err(e) = sweep(&state).await {
19 +
            tracing::error!("poller sweep failed: {e}");
20 +
        }
21 +
        tokio::time::sleep(Duration::from_secs(minutes * 60)).await;
22 +
    }
23 +
}
24 +
25 +
fn poll_interval_minutes(state: &AppState) -> u64 {
26 +
    fdb::get_setting(&state.db, POLL_INTERVAL_KEY)
27 +
        .ok()
28 +
        .flatten()
29 +
        .and_then(|v| v.parse::<u64>().ok())
30 +
        .filter(|v| *v >= 1)
31 +
        .unwrap_or(state.default_poll_minutes)
32 +
}
33 +
34 +
async fn sweep(state: &AppState) -> Result<(), String> {
35 +
    let subs = fdb::list_subscriptions(&state.db).map_err(|e| e.to_string())?;
36 +
    for sub in subs {
37 +
        if let Err(e) = poll_one(state, &sub).await {
38 +
            tracing::warn!("feed {} failed: {}", sub.feed_url, e);
39 +
            let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
40 +
            let _ = fdb::update_subscription_meta(
41 +
                &state.db,
42 +
                sub.id,
43 +
                sub.etag.as_deref(),
44 +
                sub.last_modified.as_deref(),
45 +
                &now,
46 +
                Some(&e),
47 +
            );
48 +
        }
49 +
    }
50 +
    Ok(())
51 +
}
52 +
53 +
pub async fn poll_one(state: &AppState, sub: &fdb::Subscription) -> Result<usize, String> {
54 +
    let result: FetchResult =
55 +
        fetch_feed(&sub.feed_url, sub.etag.as_deref(), sub.last_modified.as_deref()).await?;
56 +
    let now = Utc::now().format("%Y-%m-%d %H:%M:%S").to_string();
57 +
58 +
    let mut inserted = 0usize;
59 +
    if result.status != 304 {
60 +
        for entry in &result.entries {
61 +
            if entry.link.is_empty() {
62 +
                continue;
63 +
            }
64 +
            let item = fdb::NewItem {
65 +
                subscription_id: sub.id,
66 +
                guid: &entry.guid,
67 +
                title: &entry.title,
68 +
                link: &entry.link,
69 +
                author: entry.author.as_deref(),
70 +
                published_at: entry.published_at,
71 +
            };
72 +
            match fdb::insert_item_ignore_dup(&state.db, &item) {
73 +
                Ok(true) => inserted += 1,
74 +
                Ok(false) => {}
75 +
                Err(e) => tracing::warn!("insert item failed for {}: {}", sub.feed_url, e),
76 +
            }
77 +
        }
78 +
79 +
        // Refresh title if feed advertises a new one and current title looks placeholder.
80 +
        if let Some(new_title) = result.title.as_deref() {
81 +
            if !new_title.is_empty() && sub.title != new_title && sub.title == sub.feed_url {
82 +
                let _ = fdb::update_subscription_title(&state.db, sub.id, new_title);
83 +
            }
84 +
        }
85 +
86 +
        let _ = fdb::prune_subscription(&state.db, sub.id, state.item_cap as i64);
87 +
    }
88 +
89 +
    fdb::update_subscription_meta(
90 +
        &state.db,
91 +
        sub.id,
92 +
        result.etag.as_deref(),
93 +
        result.last_modified.as_deref(),
94 +
        &now,
95 +
        None,
96 +
    )
97 +
    .map_err(|e| e.to_string())?;
98 +
99 +
    tracing::info!(
100 +
        "{} status={} new={} total_entries={}",
101 +
        sub.feed_url,
102 +
        result.status,
103 +
        inserted,
104 +
        result.entries.len()
105 +
    );
106 +
    Ok(inserted)
107 +
}
apps/feeds/src/templates/admin.html +87 −34
12 12
    <title>Feeds | Admin</title>
13 13
  </head>
14 14
  <body>
15 -
    <a href="/" class="header">
16 -
      <h1>FEEDS</h1>
17 -
    </a>
18 -
    {% if !freshrss_configured %}
19 -
    <div class="admin-notice">
20 -
      <p>FreshRSS is not configured. Set <code>FRESHRSS_URL</code>, <code>FRESHRSS_USERNAME</code>, and <code>FRESHRSS_PASSWORD</code> environment variables to use this feature.</p>
15 +
    <div class="header">
16 +
      <a href="/" class="logo"><h1>FEEDS</h1></a>
17 +
      <nav class="links">
18 +
        <a href="/feeds.opml">opml</a>
19 +
        <a href="/admin/logout">logout</a>
20 +
      </nav>
21 21
    </div>
22 -
    {% else %}
23 22
24 23
    {% if let Some(msg) = success %}
25 24
    <p class="success-msg">{{ msg }}</p>
26 25
    {% endif %}
27 -
28 26
    {% if let Some(err) = error %}
29 27
    <p class="error-msg">{{ err }}</p>
30 28
    {% endif %}
31 29
32 -
    <div class="admin-form">
33 -
      <label for="base_url">Discover Feed</label>
30 +
    <section class="admin-form">
31 +
      <h3>Discover</h3>
34 32
      <div class="discover-row">
35 33
        <input type="url" id="base_url" placeholder="https://example.com" />
36 34
        <button type="button" id="discover-btn" onclick="discoverFeeds()">Discover</button>
37 35
      </div>
38 36
      <div id="discover-status" class="discover-status" style="display:none;"></div>
39 37
      <div id="discover-results" class="discover-results" style="display:none;"></div>
40 -
    </div>
38 +
    </section>
41 39
42 40
    <form class="admin-form" method="POST" action="/admin/add-feed">
41 +
      <h3>Add Feed</h3>
43 42
      <label for="feed_url">Feed URL</label>
44 43
      <input type="url" id="feed_url" name="feed_url" placeholder="https://example.com/feed.xml" required />
44 +
      <label for="category_name">Category (optional)</label>
45 +
      <input type="text" id="category_name" name="category_name" placeholder="Tech" list="categories-list" />
46 +
      <datalist id="categories-list">
47 +
        {% for c in categories %}
48 +
        <option value="{{ c.name }}"></option>
49 +
        {% endfor %}
50 +
      </datalist>
45 51
      <button type="submit">Add Feed</button>
46 52
    </form>
47 53
54 +
    <form class="admin-form" id="opml-form" method="POST" action="/admin/import-opml" enctype="multipart/form-data">
55 +
      <h3>Import OPML</h3>
56 +
      <input type="file" name="file" accept=".opml,.xml,application/xml,text/xml" required />
57 +
      <button type="submit" id="opml-submit"><span id="opml-submit-label">Import</span></button>
58 +
    </form>
59 +
60 +
    <form class="admin-form" method="POST" action="/admin/settings">
61 +
      <h3>Settings</h3>
62 +
      <label for="poll_interval_minutes">Poll interval (minutes)</label>
63 +
      <input type="number" id="poll_interval_minutes" name="poll_interval_minutes"
64 +
             min="1" max="1440" value="{{ poll_interval_minutes }}" required />
65 +
      <p class="hint">Item cap per feed: {{ item_cap }} (set via ITEM_CAP_PER_FEED)</p>
66 +
      <p class="hint">API key: {% if api_key_configured %}configured{% else %}not set{% endif %}</p>
67 +
      <button type="submit">Save</button>
68 +
    </form>
69 +
70 +
    <section class="admin-subs">
71 +
      <h3>Categories ({{ categories.len() }})</h3>
72 +
      <form class="admin-form inline" method="POST" action="/admin/categories">
73 +
        <input type="text" name="name" placeholder="New category" required />
74 +
        <button type="submit">Add</button>
75 +
      </form>
76 +
      <ul class="category-list">
77 +
        {% for c in categories %}
78 +
        <li>
79 +
          <span>{{ c.name }}</span>
80 +
          <form method="POST" action="/admin/categories/{{ c.id }}/delete" class="inline">
81 +
            <button type="submit" class="danger">Delete</button>
82 +
          </form>
83 +
        </li>
84 +
        {% endfor %}
85 +
      </ul>
86 +
    </section>
87 +
88 +
    <section class="admin-subs">
89 +
      <h3>Subscriptions ({{ subscriptions.len() }})</h3>
90 +
      <div class="feeds-list">
91 +
        {% for sub in subscriptions %}
92 +
        <div class="feed-item">
93 +
          <h3 class="feed-title">
94 +
            <a href="{{ sub.feed_url }}" target="_blank" rel="noopener noreferrer">{{ sub.title }}</a>
95 +
          </h3>
96 +
          {% if let Some(last) = sub.last_fetched_at %}
97 +
          <p class="feed-meta"><span class="feed-date">last: {{ last }}</span>{% if let Some(err) = sub.last_error %} <span class="error-msg">· {{ err }}</span>{% endif %}</p>
98 +
          {% endif %}
99 +
          <form method="POST" action="/admin/feeds/{{ sub.id }}/category" class="inline">
100 +
            <input type="text" name="category_name" placeholder="category" list="categories-list"
101 +
                   value="{% if let Some(n) = sub.category_name %}{{ n }}{% endif %}" />
102 +
            <button type="submit">Save</button>
103 +
          </form>
104 +
          <form method="POST" action="/admin/feeds/{{ sub.id }}/delete" class="inline">
105 +
            <button type="submit" class="danger">Delete</button>
106 +
          </form>
107 +
        </div>
108 +
        {% endfor %}
109 +
      </div>
110 +
    </section>
111 +
48 112
    <script>
113 +
      (function() {
114 +
        const form = document.getElementById('opml-form');
115 +
        if (!form) return;
116 +
        form.addEventListener('submit', function() {
117 +
          const btn = document.getElementById('opml-submit');
118 +
          const label = document.getElementById('opml-submit-label');
119 +
          btn.disabled = true;
120 +
          btn.classList.add('loading');
121 +
          label.innerHTML = '<span class="spinner"></span> Importing';
122 +
        });
123 +
      })();
124 +
49 125
      async function discoverFeeds() {
50 126
        const baseUrl = document.getElementById('base_url').value.trim();
51 127
        if (!baseUrl) return;
52 -
53 128
        const btn = document.getElementById('discover-btn');
54 129
        const status = document.getElementById('discover-status');
55 130
        const results = document.getElementById('discover-results');
56 131
        const feedInput = document.getElementById('feed_url');
57 -
58 132
        btn.disabled = true;
59 133
        btn.textContent = 'Searching...';
60 134
        status.style.display = 'none';
61 135
        results.style.display = 'none';
62 136
        results.innerHTML = '';
63 -
64 137
        try {
65 138
          const body = new URLSearchParams({ base_url: baseUrl });
66 139
          const resp = await fetch('/admin/discover-feeds', { method: 'POST', body });
67 140
          const data = await resp.json();
68 -
69 141
          if (!resp.ok) {
70 142
            status.textContent = data.error || 'No feeds found';
71 143
            status.className = 'discover-status error-msg';
72 144
            status.style.display = 'block';
73 145
            return;
74 146
          }
75 -
76 147
          feedInput.value = data[0];
77 148
          status.textContent = data.length + ' feed(s) found';
78 149
          status.className = 'discover-status success-msg';
79 150
          status.style.display = 'block';
80 -
81 151
          if (data.length > 1) {
82 152
            results.style.display = 'flex';
83 153
            data.forEach(function(url) {
105 175
        }
106 176
      }
107 177
    </script>
108 -
109 -
    {% if let Some(subs) = subscriptions %}
110 -
    <div class="admin-subs">
111 -
      <h3>Current Subscriptions ({{ subs.len() }})</h3>
112 -
      <div class="feeds-list">
113 -
        {% for sub in subs %}
114 -
        <div class="feed-item">
115 -
          <h3 class="feed-title">
116 -
            <a href="{{ sub.url }}" target="_blank" rel="noopener noreferrer">{{ sub.title }}</a>
117 -
          </h3>
118 -
        </div>
119 -
        {% endfor %}
120 -
      </div>
121 -
    </div>
122 -
    {% endif %}
123 -
124 -
    {% endif %}
125 178
  </body>
126 179
</html>
apps/feeds/static/styles.css +44 −0
287 287
	gap: 1rem;
288 288
}
289 289
290 +
.feed-item form.inline {
291 +
	display: flex;
292 +
	gap: 0.5rem;
293 +
	align-items: center;
294 +
}
295 +
296 +
.feed-item form.inline input {
297 +
	flex: 1;
298 +
	background: #1a1a1c;
299 +
	color: #ffffff;
300 +
	border: 1px solid #333;
301 +
	padding: 10px;
302 +
	font-family: "Commit Mono", monospace, sans-serif;
303 +
	font-size: 14px;
304 +
	outline: none;
305 +
}
306 +
307 +
.feed-item form.inline input:focus {
308 +
	border-color: #666;
309 +
}
310 +
311 +
button.loading {
312 +
	cursor: wait;
313 +
}
314 +
315 +
.spinner::after {
316 +
	content: "⠋";
317 +
	display: inline-block;
318 +
	animation: braille-spin 0.8s steps(10) infinite;
319 +
}
320 +
321 +
@keyframes braille-spin {
322 +
	0%   { content: "⠋"; }
323 +
	10%  { content: "⠙"; }
324 +
	20%  { content: "⠹"; }
325 +
	30%  { content: "⠸"; }
326 +
	40%  { content: "⠼"; }
327 +
	50%  { content: "⠴"; }
328 +
	60%  { content: "⠦"; }
329 +
	70%  { content: "⠧"; }
330 +
	80%  { content: "⠇"; }
331 +
	90%  { content: "⠏"; }
332 +
}
333 +
290 334
.admin-subs h3 {
291 335
	font-size: 14px;
292 336
	color: #888;
crates/db/Cargo.toml +2 −0
9 9
10 10
[dependencies]
11 11
rusqlite = { workspace = true }
12 +
serde = { workspace = true, optional = true }
12 13
axum = { workspace = true, optional = true }
13 14
tracing = { workspace = true, optional = true }
14 15
15 16
[features]
16 17
default = []
17 18
session = []
19 +
feeds = ["dep:serde"]
18 20
axum = ["dep:axum", "dep:tracing"]
crates/db/src/feeds.rs (added) +648 −0
1 +
use rusqlite::{params, OptionalExtension, Row};
2 +
use serde::{Deserialize, Serialize};
3 +
4 +
use crate::{Db, DbError};
5 +
6 +
pub const FEEDS_SCHEMA: &str = "
7 +
    CREATE TABLE IF NOT EXISTS categories (
8 +
        id         INTEGER PRIMARY KEY AUTOINCREMENT,
9 +
        name       TEXT NOT NULL UNIQUE,
10 +
        created_at TEXT NOT NULL DEFAULT (datetime('now'))
11 +
    );
12 +
13 +
    CREATE TABLE IF NOT EXISTS subscriptions (
14 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
15 +
        feed_url        TEXT NOT NULL UNIQUE,
16 +
        title           TEXT NOT NULL,
17 +
        site_url        TEXT,
18 +
        category_id     INTEGER REFERENCES categories(id) ON DELETE SET NULL,
19 +
        etag            TEXT,
20 +
        last_modified   TEXT,
21 +
        last_fetched_at TEXT,
22 +
        last_error      TEXT,
23 +
        added_at        TEXT NOT NULL DEFAULT (datetime('now'))
24 +
    );
25 +
    CREATE INDEX IF NOT EXISTS idx_subs_category ON subscriptions(category_id);
26 +
27 +
    CREATE TABLE IF NOT EXISTS items (
28 +
        id              INTEGER PRIMARY KEY AUTOINCREMENT,
29 +
        subscription_id INTEGER NOT NULL REFERENCES subscriptions(id) ON DELETE CASCADE,
30 +
        guid            TEXT NOT NULL,
31 +
        title           TEXT NOT NULL,
32 +
        link            TEXT NOT NULL,
33 +
        author          TEXT,
34 +
        published_at    INTEGER NOT NULL,
35 +
        is_read         INTEGER NOT NULL DEFAULT 0,
36 +
        fetched_at      TEXT NOT NULL DEFAULT (datetime('now')),
37 +
        UNIQUE(subscription_id, guid)
38 +
    );
39 +
    CREATE INDEX IF NOT EXISTS idx_items_sub_pub ON items(subscription_id, published_at DESC);
40 +
    CREATE INDEX IF NOT EXISTS idx_items_pub ON items(published_at DESC);
41 +
    CREATE INDEX IF NOT EXISTS idx_items_unread ON items(is_read, published_at DESC);
42 +
43 +
    CREATE TABLE IF NOT EXISTS settings (
44 +
        key   TEXT PRIMARY KEY,
45 +
        value TEXT NOT NULL
46 +
    );
47 +
";
48 +
49 +
#[derive(Debug, Clone, Serialize, Deserialize)]
50 +
pub struct Category {
51 +
    pub id: i64,
52 +
    pub name: String,
53 +
    pub created_at: String,
54 +
}
55 +
56 +
#[derive(Debug, Clone, Serialize, Deserialize)]
57 +
pub struct Subscription {
58 +
    pub id: i64,
59 +
    pub feed_url: String,
60 +
    pub title: String,
61 +
    pub site_url: Option<String>,
62 +
    pub category_id: Option<i64>,
63 +
    pub etag: Option<String>,
64 +
    pub last_modified: Option<String>,
65 +
    pub last_fetched_at: Option<String>,
66 +
    pub last_error: Option<String>,
67 +
    pub added_at: String,
68 +
}
69 +
70 +
#[derive(Debug, Clone, Serialize, Deserialize)]
71 +
pub struct Item {
72 +
    pub id: i64,
73 +
    pub subscription_id: i64,
74 +
    pub guid: String,
75 +
    pub title: String,
76 +
    pub link: String,
77 +
    pub author: Option<String>,
78 +
    pub published_at: i64,
79 +
    pub is_read: bool,
80 +
    pub fetched_at: String,
81 +
}
82 +
83 +
#[derive(Debug, Clone, Serialize, Deserialize)]
84 +
pub struct ItemWithFeed {
85 +
    pub id: i64,
86 +
    pub subscription_id: i64,
87 +
    pub guid: String,
88 +
    pub title: String,
89 +
    pub link: String,
90 +
    pub author: Option<String>,
91 +
    pub published_at: i64,
92 +
    pub is_read: bool,
93 +
    pub fetched_at: String,
94 +
    pub feed_title: String,
95 +
    pub feed_url: String,
96 +
    pub category_id: Option<i64>,
97 +
    pub category_name: Option<String>,
98 +
}
99 +
100 +
#[derive(Debug, Clone)]
101 +
pub struct NewItem<'a> {
102 +
    pub subscription_id: i64,
103 +
    pub guid: &'a str,
104 +
    pub title: &'a str,
105 +
    pub link: &'a str,
106 +
    pub author: Option<&'a str>,
107 +
    pub published_at: i64,
108 +
}
109 +
110 +
fn category_from_row(row: &Row) -> rusqlite::Result<Category> {
111 +
    Ok(Category {
112 +
        id: row.get(0)?,
113 +
        name: row.get(1)?,
114 +
        created_at: row.get(2)?,
115 +
    })
116 +
}
117 +
118 +
fn subscription_from_row(row: &Row) -> rusqlite::Result<Subscription> {
119 +
    Ok(Subscription {
120 +
        id: row.get(0)?,
121 +
        feed_url: row.get(1)?,
122 +
        title: row.get(2)?,
123 +
        site_url: row.get(3)?,
124 +
        category_id: row.get(4)?,
125 +
        etag: row.get(5)?,
126 +
        last_modified: row.get(6)?,
127 +
        last_fetched_at: row.get(7)?,
128 +
        last_error: row.get(8)?,
129 +
        added_at: row.get(9)?,
130 +
    })
131 +
}
132 +
133 +
const SUB_COLS: &str = "id, feed_url, title, site_url, category_id, etag, last_modified, last_fetched_at, last_error, added_at";
134 +
135 +
// ── Categories ────────────────────────────────────────────────────────
136 +
137 +
pub fn list_categories(db: &Db) -> Result<Vec<Category>, DbError> {
138 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
139 +
    let mut stmt = conn.prepare("SELECT id, name, created_at FROM categories ORDER BY name ASC")?;
140 +
    let rows = stmt
141 +
        .query_map([], category_from_row)?
142 +
        .collect::<Result<Vec<_>, _>>()?;
143 +
    Ok(rows)
144 +
}
145 +
146 +
pub fn insert_category(db: &Db, name: &str) -> Result<Category, DbError> {
147 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
148 +
    conn.execute("INSERT INTO categories (name) VALUES (?1)", params![name])?;
149 +
    let id = conn.last_insert_rowid();
150 +
    let cat = conn.query_row(
151 +
        "SELECT id, name, created_at FROM categories WHERE id = ?1",
152 +
        params![id],
153 +
        category_from_row,
154 +
    )?;
155 +
    Ok(cat)
156 +
}
157 +
158 +
pub fn get_or_create_category(db: &Db, name: &str) -> Result<Category, DbError> {
159 +
    {
160 +
        let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
161 +
        if let Some(cat) = conn
162 +
            .query_row(
163 +
                "SELECT id, name, created_at FROM categories WHERE name = ?1",
164 +
                params![name],
165 +
                category_from_row,
166 +
            )
167 +
            .optional()?
168 +
        {
169 +
            return Ok(cat);
170 +
        }
171 +
    }
172 +
    insert_category(db, name)
173 +
}
174 +
175 +
pub fn delete_category(db: &Db, id: i64) -> Result<bool, DbError> {
176 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
177 +
    let rows = conn.execute("DELETE FROM categories WHERE id = ?1", params![id])?;
178 +
    Ok(rows > 0)
179 +
}
180 +
181 +
// ── Subscriptions ─────────────────────────────────────────────────────
182 +
183 +
pub fn list_subscriptions(db: &Db) -> Result<Vec<Subscription>, DbError> {
184 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
185 +
    let mut stmt = conn.prepare(&format!(
186 +
        "SELECT {SUB_COLS} FROM subscriptions ORDER BY title COLLATE NOCASE ASC"
187 +
    ))?;
188 +
    let rows = stmt
189 +
        .query_map([], subscription_from_row)?
190 +
        .collect::<Result<Vec<_>, _>>()?;
191 +
    Ok(rows)
192 +
}
193 +
194 +
pub fn get_subscription(db: &Db, id: i64) -> Result<Option<Subscription>, DbError> {
195 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
196 +
    let sub = conn
197 +
        .query_row(
198 +
            &format!("SELECT {SUB_COLS} FROM subscriptions WHERE id = ?1"),
199 +
            params![id],
200 +
            subscription_from_row,
201 +
        )
202 +
        .optional()?;
203 +
    Ok(sub)
204 +
}
205 +
206 +
pub fn get_subscription_by_url(db: &Db, feed_url: &str) -> Result<Option<Subscription>, DbError> {
207 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
208 +
    let sub = conn
209 +
        .query_row(
210 +
            &format!("SELECT {SUB_COLS} FROM subscriptions WHERE feed_url = ?1"),
211 +
            params![feed_url],
212 +
            subscription_from_row,
213 +
        )
214 +
        .optional()?;
215 +
    Ok(sub)
216 +
}
217 +
218 +
pub fn insert_subscription(
219 +
    db: &Db,
220 +
    feed_url: &str,
221 +
    title: &str,
222 +
    site_url: Option<&str>,
223 +
    category_id: Option<i64>,
224 +
) -> Result<Subscription, DbError> {
225 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
226 +
    conn.execute(
227 +
        "INSERT INTO subscriptions (feed_url, title, site_url, category_id) VALUES (?1, ?2, ?3, ?4)",
228 +
        params![feed_url, title, site_url, category_id],
229 +
    )?;
230 +
    let id = conn.last_insert_rowid();
231 +
    let sub = conn.query_row(
232 +
        &format!("SELECT {SUB_COLS} FROM subscriptions WHERE id = ?1"),
233 +
        params![id],
234 +
        subscription_from_row,
235 +
    )?;
236 +
    Ok(sub)
237 +
}
238 +
239 +
pub fn update_subscription_meta(
240 +
    db: &Db,
241 +
    id: i64,
242 +
    etag: Option<&str>,
243 +
    last_modified: Option<&str>,
244 +
    last_fetched_at: &str,
245 +
    last_error: Option<&str>,
246 +
) -> Result<(), DbError> {
247 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
248 +
    conn.execute(
249 +
        "UPDATE subscriptions SET etag = ?1, last_modified = ?2, last_fetched_at = ?3, last_error = ?4 WHERE id = ?5",
250 +
        params![etag, last_modified, last_fetched_at, last_error, id],
251 +
    )?;
252 +
    Ok(())
253 +
}
254 +
255 +
pub fn update_subscription_title(db: &Db, id: i64, title: &str) -> Result<(), DbError> {
256 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
257 +
    conn.execute(
258 +
        "UPDATE subscriptions SET title = ?1 WHERE id = ?2",
259 +
        params![title, id],
260 +
    )?;
261 +
    Ok(())
262 +
}
263 +
264 +
pub fn update_subscription_category(
265 +
    db: &Db,
266 +
    id: i64,
267 +
    category_id: Option<i64>,
268 +
) -> Result<(), DbError> {
269 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
270 +
    conn.execute(
271 +
        "UPDATE subscriptions SET category_id = ?1 WHERE id = ?2",
272 +
        params![category_id, id],
273 +
    )?;
274 +
    Ok(())
275 +
}
276 +
277 +
pub fn delete_subscription(db: &Db, id: i64) -> Result<bool, DbError> {
278 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
279 +
    let rows = conn.execute("DELETE FROM subscriptions WHERE id = ?1", params![id])?;
280 +
    Ok(rows > 0)
281 +
}
282 +
283 +
// ── Items ─────────────────────────────────────────────────────────────
284 +
285 +
/// Insert if new. Returns true if inserted, false if a duplicate (guid) existed.
286 +
pub fn insert_item_ignore_dup(db: &Db, item: &NewItem<'_>) -> Result<bool, DbError> {
287 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
288 +
    let rows = conn.execute(
289 +
        "INSERT OR IGNORE INTO items (subscription_id, guid, title, link, author, published_at)
290 +
         VALUES (?1, ?2, ?3, ?4, ?5, ?6)",
291 +
        params![
292 +
            item.subscription_id,
293 +
            item.guid,
294 +
            item.title,
295 +
            item.link,
296 +
            item.author,
297 +
            item.published_at,
298 +
        ],
299 +
    )?;
300 +
    Ok(rows > 0)
301 +
}
302 +
303 +
#[derive(Debug, Clone, Default)]
304 +
pub struct ListItemsFilter {
305 +
    pub limit: Option<i64>,
306 +
    pub unread_only: bool,
307 +
    pub category_id: Option<i64>,
308 +
    pub subscription_id: Option<i64>,
309 +
}
310 +
311 +
pub fn list_items(db: &Db, filter: &ListItemsFilter) -> Result<Vec<ItemWithFeed>, DbError> {
312 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
313 +
314 +
    let mut sql = String::from(
315 +
        "SELECT i.id, i.subscription_id, i.guid, i.title, i.link, i.author, i.published_at,
316 +
                i.is_read, i.fetched_at, s.title, s.feed_url, s.category_id, c.name
317 +
         FROM items i
318 +
         JOIN subscriptions s ON s.id = i.subscription_id
319 +
         LEFT JOIN categories c ON c.id = s.category_id
320 +
         WHERE 1=1",
321 +
    );
322 +
    let mut binds: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
323 +
324 +
    if filter.unread_only {
325 +
        sql.push_str(" AND i.is_read = 0");
326 +
    }
327 +
    if let Some(cid) = filter.category_id {
328 +
        sql.push_str(&format!(" AND s.category_id = ?{}", binds.len() + 1));
329 +
        binds.push(Box::new(cid));
330 +
    }
331 +
    if let Some(sid) = filter.subscription_id {
332 +
        sql.push_str(&format!(" AND i.subscription_id = ?{}", binds.len() + 1));
333 +
        binds.push(Box::new(sid));
334 +
    }
335 +
336 +
    sql.push_str(" ORDER BY i.published_at DESC, i.id DESC");
337 +
338 +
    let limit = filter.limit.unwrap_or(100).clamp(1, 1000);
339 +
    sql.push_str(&format!(" LIMIT ?{}", binds.len() + 1));
340 +
    binds.push(Box::new(limit));
341 +
342 +
    let mut stmt = conn.prepare(&sql)?;
343 +
    let params_slice: Vec<&dyn rusqlite::ToSql> = binds.iter().map(|b| b.as_ref()).collect();
344 +
    let rows = stmt
345 +
        .query_map(params_slice.as_slice(), |row| {
346 +
            Ok(ItemWithFeed {
347 +
                id: row.get(0)?,
348 +
                subscription_id: row.get(1)?,
349 +
                guid: row.get(2)?,
350 +
                title: row.get(3)?,
351 +
                link: row.get(4)?,
352 +
                author: row.get(5)?,
353 +
                published_at: row.get(6)?,
354 +
                is_read: row.get::<_, i64>(7)? != 0,
355 +
                fetched_at: row.get(8)?,
356 +
                feed_title: row.get(9)?,
357 +
                feed_url: row.get(10)?,
358 +
                category_id: row.get(11)?,
359 +
                category_name: row.get(12)?,
360 +
            })
361 +
        })?
362 +
        .collect::<Result<Vec<_>, _>>()?;
363 +
    Ok(rows)
364 +
}
365 +
366 +
pub fn mark_read(db: &Db, id: i64) -> Result<bool, DbError> {
367 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
368 +
    let rows = conn.execute("UPDATE items SET is_read = 1 WHERE id = ?1", params![id])?;
369 +
    Ok(rows > 0)
370 +
}
371 +
372 +
pub fn mark_unread(db: &Db, id: i64) -> Result<bool, DbError> {
373 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
374 +
    let rows = conn.execute("UPDATE items SET is_read = 0 WHERE id = ?1", params![id])?;
375 +
    Ok(rows > 0)
376 +
}
377 +
378 +
/// Keep newest `keep_n` items for a subscription, delete older.
379 +
pub fn prune_subscription(db: &Db, subscription_id: i64, keep_n: i64) -> Result<usize, DbError> {
380 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
381 +
    let rows = conn.execute(
382 +
        "DELETE FROM items
383 +
         WHERE subscription_id = ?1
384 +
           AND id NOT IN (
385 +
             SELECT id FROM items
386 +
             WHERE subscription_id = ?1
387 +
             ORDER BY published_at DESC, id DESC
388 +
             LIMIT ?2
389 +
           )",
390 +
        params![subscription_id, keep_n],
391 +
    )?;
392 +
    Ok(rows)
393 +
}
394 +
395 +
// ── Settings ──────────────────────────────────────────────────────────
396 +
397 +
pub fn get_setting(db: &Db, key: &str) -> Result<Option<String>, DbError> {
398 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
399 +
    let val = conn
400 +
        .query_row(
401 +
            "SELECT value FROM settings WHERE key = ?1",
402 +
            params![key],
403 +
            |row| row.get::<_, String>(0),
404 +
        )
405 +
        .optional()?;
406 +
    Ok(val)
407 +
}
408 +
409 +
pub fn set_setting(db: &Db, key: &str, value: &str) -> Result<(), DbError> {
410 +
    let conn = db.lock().map_err(|_| DbError::LockPoisoned)?;
411 +
    conn.execute(
412 +
        "INSERT INTO settings (key, value) VALUES (?1, ?2)
413 +
         ON CONFLICT(key) DO UPDATE SET value = excluded.value",
414 +
        params![key, value],
415 +
    )?;
416 +
    Ok(())
417 +
}
418 +
419 +
#[cfg(test)]
420 +
mod tests {
421 +
    use super::*;
422 +
    use rusqlite::Connection;
423 +
    use std::sync::{Arc, Mutex};
424 +
425 +
    fn test_db() -> Db {
426 +
        let conn = Connection::open_in_memory().unwrap();
427 +
        conn.execute_batch(FEEDS_SCHEMA).unwrap();
428 +
        Arc::new(Mutex::new(conn))
429 +
    }
430 +
431 +
    #[test]
432 +
    fn category_crud() {
433 +
        let db = test_db();
434 +
        let cat = insert_category(&db, "Tech").unwrap();
435 +
        assert_eq!(cat.name, "Tech");
436 +
437 +
        let all = list_categories(&db).unwrap();
438 +
        assert_eq!(all.len(), 1);
439 +
440 +
        let same = get_or_create_category(&db, "Tech").unwrap();
441 +
        assert_eq!(same.id, cat.id);
442 +
443 +
        let other = get_or_create_category(&db, "News").unwrap();
444 +
        assert_ne!(other.id, cat.id);
445 +
446 +
        assert!(delete_category(&db, cat.id).unwrap());
447 +
        assert_eq!(list_categories(&db).unwrap().len(), 1);
448 +
    }
449 +
450 +
    #[test]
451 +
    fn subscription_crud() {
452 +
        let db = test_db();
453 +
        let sub = insert_subscription(
454 +
            &db,
455 +
            "https://example.com/feed",
456 +
            "Example",
457 +
            Some("https://example.com"),
458 +
            None,
459 +
        )
460 +
        .unwrap();
461 +
        assert_eq!(sub.title, "Example");
462 +
463 +
        let fetched = get_subscription_by_url(&db, "https://example.com/feed")
464 +
            .unwrap()
465 +
            .unwrap();
466 +
        assert_eq!(fetched.id, sub.id);
467 +
468 +
        update_subscription_meta(
469 +
            &db,
470 +
            sub.id,
471 +
            Some("etag-1"),
472 +
            Some("Sun, 01 Jan 2024 00:00:00 GMT"),
473 +
            "2024-01-01 00:00:00",
474 +
            None,
475 +
        )
476 +
        .unwrap();
477 +
        let after = get_subscription(&db, sub.id).unwrap().unwrap();
478 +
        assert_eq!(after.etag.as_deref(), Some("etag-1"));
479 +
480 +
        assert!(delete_subscription(&db, sub.id).unwrap());
481 +
        assert!(get_subscription(&db, sub.id).unwrap().is_none());
482 +
    }
483 +
484 +
    #[test]
485 +
    fn item_insert_dedup_and_list() {
486 +
        let db = test_db();
487 +
        let sub = insert_subscription(&db, "https://a.com/feed", "A", None, None).unwrap();
488 +
489 +
        let inserted = insert_item_ignore_dup(
490 +
            &db,
491 +
            &NewItem {
492 +
                subscription_id: sub.id,
493 +
                guid: "g1",
494 +
                title: "Post 1",
495 +
                link: "https://a.com/1",
496 +
                author: Some("Alice"),
497 +
                published_at: 1_700_000_000,
498 +
            },
499 +
        )
500 +
        .unwrap();
501 +
        assert!(inserted);
502 +
503 +
        let dup = insert_item_ignore_dup(
504 +
            &db,
505 +
            &NewItem {
506 +
                subscription_id: sub.id,
507 +
                guid: "g1",
508 +
                title: "Post 1 different title",
509 +
                link: "https://a.com/1",
510 +
                author: None,
511 +
                published_at: 1_700_000_000,
512 +
            },
513 +
        )
514 +
        .unwrap();
515 +
        assert!(!dup);
516 +
517 +
        let items = list_items(&db, &ListItemsFilter::default()).unwrap();
518 +
        assert_eq!(items.len(), 1);
519 +
        assert_eq!(items[0].title, "Post 1");
520 +
        assert_eq!(items[0].feed_title, "A");
521 +
        assert!(!items[0].is_read);
522 +
    }
523 +
524 +
    #[test]
525 +
    fn mark_read_unread() {
526 +
        let db = test_db();
527 +
        let sub = insert_subscription(&db, "https://a.com/feed", "A", None, None).unwrap();
528 +
        insert_item_ignore_dup(
529 +
            &db,
530 +
            &NewItem {
531 +
                subscription_id: sub.id,
532 +
                guid: "g",
533 +
                title: "t",
534 +
                link: "l",
535 +
                author: None,
536 +
                published_at: 1,
537 +
            },
538 +
        )
539 +
        .unwrap();
540 +
        let items = list_items(&db, &ListItemsFilter::default()).unwrap();
541 +
        let id = items[0].id;
542 +
543 +
        assert!(mark_read(&db, id).unwrap());
544 +
        let read = list_items(
545 +
            &db,
546 +
            &ListItemsFilter {
547 +
                unread_only: true,
548 +
                ..Default::default()
549 +
            },
550 +
        )
551 +
        .unwrap();
552 +
        assert_eq!(read.len(), 0);
553 +
554 +
        assert!(mark_unread(&db, id).unwrap());
555 +
        let unread = list_items(
556 +
            &db,
557 +
            &ListItemsFilter {
558 +
                unread_only: true,
559 +
                ..Default::default()
560 +
            },
561 +
        )
562 +
        .unwrap();
563 +
        assert_eq!(unread.len(), 1);
564 +
    }
565 +
566 +
    #[test]
567 +
    fn prune_keeps_newest() {
568 +
        let db = test_db();
569 +
        let sub = insert_subscription(&db, "https://a.com/feed", "A", None, None).unwrap();
570 +
        for i in 0..10 {
571 +
            insert_item_ignore_dup(
572 +
                &db,
573 +
                &NewItem {
574 +
                    subscription_id: sub.id,
575 +
                    guid: &format!("g{i}"),
576 +
                    title: "t",
577 +
                    link: "l",
578 +
                    author: None,
579 +
                    published_at: i as i64,
580 +
                },
581 +
            )
582 +
            .unwrap();
583 +
        }
584 +
        let removed = prune_subscription(&db, sub.id, 3).unwrap();
585 +
        assert_eq!(removed, 7);
586 +
587 +
        let items = list_items(&db, &ListItemsFilter::default()).unwrap();
588 +
        assert_eq!(items.len(), 3);
589 +
        assert_eq!(items[0].published_at, 9);
590 +
        assert_eq!(items[2].published_at, 7);
591 +
    }
592 +
593 +
    #[test]
594 +
    fn settings_upsert() {
595 +
        let db = test_db();
596 +
        assert!(get_setting(&db, "poll").unwrap().is_none());
597 +
        set_setting(&db, "poll", "30").unwrap();
598 +
        assert_eq!(get_setting(&db, "poll").unwrap().as_deref(), Some("30"));
599 +
        set_setting(&db, "poll", "60").unwrap();
600 +
        assert_eq!(get_setting(&db, "poll").unwrap().as_deref(), Some("60"));
601 +
    }
602 +
603 +
    #[test]
604 +
    fn category_filter_on_items() {
605 +
        let db = test_db();
606 +
        let tech = insert_category(&db, "Tech").unwrap();
607 +
        let sub_tech =
608 +
            insert_subscription(&db, "https://a.com/feed", "A", None, Some(tech.id)).unwrap();
609 +
        let sub_other = insert_subscription(&db, "https://b.com/feed", "B", None, None).unwrap();
610 +
611 +
        insert_item_ignore_dup(
612 +
            &db,
613 +
            &NewItem {
614 +
                subscription_id: sub_tech.id,
615 +
                guid: "g1",
616 +
                title: "tech post",
617 +
                link: "",
618 +
                author: None,
619 +
                published_at: 1,
620 +
            },
621 +
        )
622 +
        .unwrap();
623 +
        insert_item_ignore_dup(
624 +
            &db,
625 +
            &NewItem {
626 +
                subscription_id: sub_other.id,
627 +
                guid: "g2",
628 +
                title: "other post",
629 +
                link: "",
630 +
                author: None,
631 +
                published_at: 2,
632 +
            },
633 +
        )
634 +
        .unwrap();
635 +
636 +
        let tech_items = list_items(
637 +
            &db,
638 +
            &ListItemsFilter {
639 +
                category_id: Some(tech.id),
640 +
                ..Default::default()
641 +
            },
642 +
        )
643 +
        .unwrap();
644 +
        assert_eq!(tech_items.len(), 1);
645 +
        assert_eq!(tech_items[0].title, "tech post");
646 +
        assert_eq!(tech_items[0].category_name.as_deref(), Some("Tech"));
647 +
    }
648 +
}
crates/db/src/lib.rs +3 −0
45 45
46 46
#[cfg(feature = "session")]
47 47
pub mod session;
48 +
49 +
#[cfg(feature = "feeds")]
50 +
pub mod feeds;