Homebrew RSS reader server
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

api: replace fever with miniflux v1 api

Hard cutover from Fever API to Miniflux-compatible v1 API with
DB as source of truth for categories/feeds/items.

* add migrations for items.is_starred and items.changed_at
* remove fever handler, routes, and all fever tests
* replace destructive sync_config with one-time legacy bootstrap
that only imports from config when DB is empty
* add miniflux auth middleware (X-Auth-Token + HTTP Basic)
* implement full miniflux endpoint surface: categories/feeds/entries
CRUD, counters, icons, OPML export, bookmarks, health, version
* add broadcast channel for manual feed refresh triggers
* replace CLI auth/add commands with token generator and DB-based
OPML import
* add 30 integration tests covering auth, CRUD, filtering, and
status updates

Co-authored-by: Claude <noreply@anthropic.com>

+2722 -725
+11 -4
Cargo.lock
··· 1074 1074 ] 1075 1075 1076 1076 [[package]] 1077 + name = "md5" 1078 + version = "0.7.0" 1079 + source = "registry+https://github.com/rust-lang/crates.io-index" 1080 + checksum = "490cc448043f947bae3cbee9c203358d62dbee0db12107a74be5c30ccfd09771" 1081 + 1082 + [[package]] 1077 1083 name = "mediatype" 1078 1084 version = "0.19.20" 1079 1085 source = "registry+https://github.com/rust-lang/crates.io-index" ··· 1756 1762 1757 1763 [[package]] 1758 1764 name = "slurp" 1759 - version = "0.2.0" 1765 + version = "0.3.0" 1760 1766 dependencies = [ 1761 1767 "anyhow", 1762 1768 "axum", ··· 1764 1770 "chrono", 1765 1771 "clap", 1766 1772 "feed-rs", 1767 - "md-5", 1773 + "getrandom 0.3.4", 1774 + "http-body-util", 1775 + "md5", 1768 1776 "opml", 1769 1777 "reqwest", 1770 1778 "serde", 1771 1779 "serde_json", 1772 - "serde_urlencoded", 1773 1780 "sqlx", 1774 1781 "tokio", 1775 1782 "toml", 1776 - "toml_edit", 1783 + "tower", 1777 1784 "tracing", 1778 1785 "tracing-subscriber", 1779 1786 "url",
+7 -4
Cargo.toml
··· 1 1 [package] 2 2 name = "slurp" 3 - version = "0.2.0" 3 + version = "0.3.0" 4 4 edition = "2024" 5 5 6 6 [dependencies] ··· 12 12 clap = { version = "4.5", features = ["derive"] } 13 13 serde = { version = "1", features = ["derive"] } 14 14 serde_json = "1" 15 - serde_urlencoded = "0.7" 16 15 toml = "0.8" 17 - toml_edit = "0.22" 18 16 tracing = "0.1" 19 17 tracing-subscriber = "0.3" 20 - md-5 = "0.10" 18 + md5 = "0.7" 21 19 base64 = "0.22" 22 20 opml = "1.1" 23 21 chrono = "0.4" 24 22 anyhow = "1" 25 23 url = "2.5.8" 24 + getrandom = "0.3" 25 + 26 + [dev-dependencies] 27 + tower = { version = "0.5", features = ["util"] } 28 + http-body-util = "0.1"
+320
impl.md
··· 1 + # Miniflux v2 API Cutover Plan (Fever Removed, DB Source of Truth) 2 + 3 + ## Decisions (Locked In) 4 + 5 + 1. **Remove Fever API immediately** (no dual-stack period). 6 + 2. **Expose only Miniflux-compatible API endpoints**. 7 + 3. **SQLite is the source of truth** for categories/feeds/items. 8 + 4. `slurp.toml` becomes **runtime config only** (bind, db path, fetch interval, auth token). 9 + 5. Existing `[[groups]]` / `[[feeds]]` in `slurp.toml` are treated as **legacy bootstrap input only** (one-time import), then ignored/removed. 10 + 11 + --- 12 + 13 + ## Current State Summary 14 + 15 + - **Schema**: `groups`, `feeds`, `items`, `favicons` 16 + - **API**: Fever-only handler on `/`, `/fever`, `/fever/` 17 + - **Auth**: Fever-style md5 API key in POST body 18 + - **Config behavior**: `db::sync_config()` enforces config as source of truth (including deletions) 19 + - **Gaps for Miniflux**: 20 + - no `is_starred` 21 + - no Miniflux `/v1/*` routes 22 + - favicon format stored as combined `"mime;base64,DATA"` 23 + 24 + --- 25 + 26 + ## Step 1: Database Migrations 27 + 28 + ### 1a) Add starred state 29 + 30 + **File**: `migrations/003_starred.sql` 31 + 32 + Add: 33 + - `items.is_starred INTEGER NOT NULL DEFAULT 0 CHECK(is_starred IN (0,1))` 34 + - index on `items(is_starred)` 35 + 36 + ### 1b) Add change tracking timestamp (recommended) 37 + 38 + **File**: `migrations/004_changed_at.sql` 39 + 40 + Add: 41 + - `items.changed_at INTEGER NOT NULL DEFAULT (unixepoch())` 42 + - index on `items(changed_at)` 43 + 44 + `changed_at` should be updated whenever read/starred status changes. 45 + 46 + --- 47 + 48 + ## Step 2: Move Source of Truth from Config to DB 49 + 50 + ### 2a) Stop destructive config sync 51 + 52 + **File**: `src/main.rs` 53 + 54 + - Remove call to `db::sync_config(&pool, &config)` in `serve`. 55 + - Replace with one-time legacy bootstrap: 56 + - if DB has zero groups/feeds and config still contains legacy sections, import them. 57 + - otherwise do nothing. 58 + 59 + ### 2b) Update config model 60 + 61 + **File**: `src/config.rs` 62 + 63 + - Keep runtime config fields: 64 + - `server.bind` 65 + - `server.api_key` (used as Miniflux auth token) 66 + - `database.path` 67 + - `fetcher.interval_minutes` 68 + - Mark `groups`/`feeds` as deprecated legacy bootstrap input. 69 + 70 + ### 2c) Add bootstrap helper 71 + 72 + **File**: `src/db.rs` 73 + 74 + Add `bootstrap_from_legacy_config_if_empty(...)`: 75 + - If DB empty, insert groups + feeds from config. 76 + - Never delete DB rows based on config. 77 + 78 + --- 79 + 80 + ## Step 3: Remove Fever Completely 81 + 82 + Delete/remove: 83 + - `src/api/fever.rs` 84 + - Fever routes in `src/server.rs` 85 + - Fever test files: 86 + - `tests/fever_test.rs` 87 + - `tests/fever_integration.rs` 88 + - Fever-specific comments/types in `src/db.rs` naming 89 + - md5-focused CLI behavior tied only to Fever compatibility 90 + 91 + **Result**: No `/fever` and no Fever request handling. 92 + 93 + --- 94 + 95 + ## Step 4: Auth Model for Miniflux 96 + 97 + **Files**: 98 + - `src/api/miniflux/auth.rs` (new) 99 + - `src/config.rs` 100 + 101 + Implement an auth extractor/middleware that accepts: 102 + - `X-Auth-Token: <server.api_key>` 103 + - HTTP Basic (`username:password`) where `password == server.api_key` 104 + 105 + On failure, return: 106 + - `401 Unauthorized` 107 + - JSON: `{"error_message":"Access Unauthorized"}` 108 + 109 + --- 110 + 111 + ## Step 5: Miniflux API Types 112 + 113 + **File**: `src/api/miniflux/types.rs` (new) 114 + 115 + Define serializable response structs for: 116 + - `/v1/me` 117 + - categories 118 + - feeds (with nested category/icon) 119 + - entries + entries envelope (`{ total, entries }`) 120 + - icons 121 + - counters (`{ reads: {feed_id: count}, unreads: {feed_id: count} }`) 122 + - version payload 123 + 124 + Use sane defaults for unsupported Miniflux fields (`""`, `false`, `null`, etc.). 125 + 126 + --- 127 + 128 + ## Step 6: DB Query/Command Layer for Miniflux 129 + 130 + **File**: `src/db.rs` 131 + 132 + Add/adjust functions: 133 + 134 + ### Categories 135 + 1. `get_categories(...)` 136 + 2. `get_category(...)` 137 + 3. `create_category(title)` 138 + 4. `update_category(id, title)` 139 + 5. `delete_category(id)` 140 + 6. `get_category_counts(...)` (`feed_count`, `total_unread`) 141 + 142 + ### Feeds 143 + 7. `get_feeds_with_categories(...)` 144 + 8. `get_feed_with_category(id)` 145 + 9. `create_feed(feed_url, category_id)` 146 + 10. `update_feed(id, fields...)` 147 + 11. `delete_feed(id)` 148 + 12. `get_category_feeds(category_id)` 149 + 13. `mark_all_feed_entries_read(feed_id)` 150 + 14. `mark_all_category_entries_read(category_id)` 151 + 15. `get_feed_counters()` -> Miniflux counters shape 152 + 153 + ### Entries 154 + 16. `get_entry(id)` 155 + 17. `get_entries_filtered(filter) -> (total, Vec<Entry>)` 156 + 18. `update_entries_status(entry_ids, status)` 157 + 19. `toggle_entry_starred(id)` 158 + 159 + ### Icons 160 + 20. `get_icon_by_id(id)` 161 + 21. `get_icon_by_feed_id(feed_id)` 162 + 163 + ### Query Builder constraints 164 + - Build dynamic filters with `sqlx::QueryBuilder` 165 + - Whitelist `order` and `direction` 166 + - Clamp `limit` to a max (e.g. 500) 167 + 168 + --- 169 + 170 + ## Step 7: Implement Miniflux Handlers 171 + 172 + **File**: `src/api/miniflux/handlers.rs` (new) 173 + 174 + ### 7a) User 175 + - `GET /v1/me` 176 + 177 + ### 7b) Categories (now writable) 178 + - `GET /v1/categories` (`?counts=true` support) 179 + - `POST /v1/categories` 180 + - `PUT /v1/categories/:id` 181 + - `DELETE /v1/categories/:id` 182 + - `PUT /v1/categories/:id/mark-all-as-read` 183 + - `GET /v1/categories/:id/entries` 184 + - `GET /v1/categories/:id/feeds` 185 + 186 + ### 7c) Feeds (now writable) 187 + - `GET /v1/feeds` 188 + - `GET /v1/feeds/:id` 189 + - `POST /v1/feeds` (add feed through API) 190 + - `PUT /v1/feeds/:id` 191 + - `DELETE /v1/feeds/:id` 192 + - `GET /v1/feeds/:id/icon` 193 + - `GET /v1/feeds/:id/entries` 194 + - `PUT /v1/feeds/:id/mark-all-as-read` 195 + - `PUT /v1/feeds/:id/refresh` 196 + - `PUT /v1/feeds/refresh` 197 + 198 + ### 7d) Entries 199 + - `GET /v1/entries` 200 + - `GET /v1/entries/:id` 201 + - `PUT /v1/entries` (`{ entry_ids, status }`) 202 + - `PUT /v1/entries/:id/bookmark` 203 + 204 + ### 7e) Icons 205 + - `GET /v1/icons/:id` 206 + 207 + ### 7f) OPML 208 + - `GET /v1/export` 209 + - (optional) `POST /v1/import` 210 + 211 + ### 7g) Health/Version 212 + - `GET /healthcheck` (root path) 213 + - `GET /liveness` and `/healthz` (root paths) 214 + - `GET /readiness` and `/readyz` (root paths) 215 + - `GET /v1/version` 216 + - `GET /v1/feeds/counters` 217 + 218 + --- 219 + 220 + ## Step 8: Router Wiring 221 + 222 + **Files**: 223 + - `src/api/miniflux/mod.rs` (new) 224 + - `src/api/mod.rs` 225 + - `src/server.rs` 226 + 227 + Target shape: 228 + 229 + ```rust 230 + Router::new() 231 + .nest("/v1", miniflux_router()) 232 + .route("/healthcheck", get(...)) 233 + .route("/liveness", get(...)) 234 + .route("/healthz", get(...)) 235 + .route("/readiness", get(...)) 236 + .route("/readyz", get(...)) 237 + .with_state(state) 238 + ``` 239 + 240 + No Fever routes are registered. 241 + 242 + --- 243 + 244 + ## Step 9: Fetcher Trigger Integration 245 + 246 + **Files**: `src/fetcher.rs`, `src/main.rs`, handlers 247 + 248 + - Keep periodic fetch loop. 249 + - Add an internal trigger mechanism for: 250 + - `PUT /v1/feeds/:id/refresh` 251 + - `PUT /v1/feeds/refresh` 252 + - Ensure manual refresh does not race badly with periodic runs. 253 + 254 + --- 255 + 256 + ## Step 10: CLI Cleanup 257 + 258 + **File**: `src/main.rs` 259 + 260 + - Remove Fever-specific md5 auth expectations. 261 + - `Auth` subcommand should be removed or repurposed to generate a random token. 262 + - `Add`/`Import` should write to DB (or be removed if API-first is preferred). 263 + 264 + --- 265 + 266 + ## Step 11: Testing 267 + 268 + 1. Remove Fever tests. 269 + 2. Add Miniflux integration tests for: 270 + - auth (`X-Auth-Token`, Basic) 271 + - feed/category CRUD 272 + - entries filtering and pagination 273 + - status updates and bookmark toggle 274 + - counters shape 275 + - health/version endpoints 276 + 3. Verify with real client against `/v1`. 277 + 278 + --- 279 + 280 + ## File Layout After Cutover 281 + 282 + ``` 283 + src/ 284 + api/ 285 + mod.rs ← only `pub mod miniflux;` 286 + miniflux/ 287 + mod.rs 288 + auth.rs 289 + types.rs 290 + handlers.rs 291 + config.rs 292 + db.rs 293 + fetcher.rs 294 + server.rs 295 + main.rs 296 + migrations/ 297 + 001_initial.sql 298 + 002_read_status.sql 299 + 003_starred.sql 300 + 004_changed_at.sql 301 + tests/ 302 + miniflux_*.rs 303 + ``` 304 + 305 + --- 306 + 307 + ## Suggested Implementation Order 308 + 309 + 1. Migrations (`003`, `004`) 310 + 2. Remove Fever routes/files/tests 311 + 3. Stop `sync_config` as source of truth; add bootstrap-if-empty 312 + 4. Add Miniflux auth extractor 313 + 5. Add Miniflux types + DB functions 314 + 6. Implement handlers (user → categories → feeds → entries → icons → counters) 315 + 7. Wire router + health/version endpoints 316 + 8. Fetch trigger integration 317 + 9. CLI cleanup 318 + 10. Integration testing with real client 319 + 320 + This delivers a clean cutover: **Miniflux-only API with DB-owned feeds/categories and no Fever compatibility layer**.
+2
migrations/003_starred.sql
··· 1 + ALTER TABLE items ADD COLUMN is_starred INTEGER NOT NULL DEFAULT 0 CHECK(is_starred IN (0,1)); 2 + CREATE INDEX idx_items_is_starred ON items(is_starred);
+3
migrations/004_changed_at.sql
··· 1 + ALTER TABLE items ADD COLUMN changed_at INTEGER NOT NULL DEFAULT 0; 2 + UPDATE items SET changed_at = created_at; 3 + CREATE INDEX idx_items_changed_at ON items(changed_at);
-122
src/api/fever.rs
··· 1 - use axum::body::Bytes; 2 - use axum::extract::{Query, State}; 3 - use axum::response::Json; 4 - use serde_json::{json, Value}; 5 - use std::collections::HashMap; 6 - use tracing::debug; 7 - 8 - use crate::db; 9 - use crate::server::AppState; 10 - 11 - pub async fn handler( 12 - State(state): State<AppState>, 13 - Query(params): Query<HashMap<String, String>>, 14 - body: Bytes, 15 - ) -> Json<Value> { 16 - debug!(?params, "fever request query params"); 17 - debug!(body = %String::from_utf8_lossy(&body), "fever request body"); 18 - 19 - // parse form body (may be empty for GET requests) 20 - let body_params: HashMap<String, String> = 21 - serde_urlencoded::from_bytes(&body).unwrap_or_default(); 22 - 23 - debug!(?body_params, "fever parsed body params"); 24 - 25 - let api_key = body_params.get("api_key").cloned().unwrap_or_default(); 26 - let authed = api_key.eq_ignore_ascii_case(&state.api_key); 27 - 28 - debug!( 29 - api_key_provided = %api_key, 30 - api_key_expected = %state.api_key, 31 - authed, 32 - "fever auth check" 33 - ); 34 - 35 - let now = chrono::Utc::now().timestamp(); 36 - let mut response = json!({ 37 - "api_version": 3, 38 - "auth": if authed { 1 } else { 0 }, 39 - "last_refreshed_on_time": now, 40 - }); 41 - 42 - if !authed { 43 - return Json(response); 44 - } 45 - 46 - let pool = &state.pool; 47 - 48 - if params.contains_key("groups") { 49 - if let Ok(groups) = db::get_groups(pool).await { 50 - response["groups"] = json!(groups); 51 - } 52 - if let Ok(fg) = db::get_feeds_groups(pool).await { 53 - response["feeds_groups"] = json!(fg); 54 - } 55 - } 56 - 57 - if params.contains_key("feeds") { 58 - if let Ok(feeds) = db::get_feeds(pool).await { 59 - response["feeds"] = json!(feeds); 60 - } 61 - if let Ok(fg) = db::get_feeds_groups(pool).await { 62 - response["feeds_groups"] = json!(fg); 63 - } 64 - } 65 - 66 - if params.contains_key("items") { 67 - let since_id = params.get("since_id").and_then(|v| v.parse().ok()); 68 - let max_id = params.get("max_id").and_then(|v| v.parse().ok()); 69 - let with_ids: Option<Vec<i64>> = params.get("with_ids").map(|v| { 70 - v.split(',') 71 - .filter_map(|s| s.trim().parse().ok()) 72 - .collect() 73 - }); 74 - 75 - if let Ok(items) = db::get_items(pool, since_id, max_id, with_ids.as_deref()).await { 76 - response["items"] = json!(items); 77 - } 78 - } 79 - 80 - if params.contains_key("favicons") 81 - && let Ok(favicons) = db::get_favicons(pool).await { 82 - response["favicons"] = json!(favicons); 83 - } 84 - 85 - if params.contains_key("unread_item_ids") 86 - && let Ok(ids) = db::get_unread_item_ids(pool).await { 87 - response["unread_item_ids"] = json!(ids); 88 - } 89 - 90 - if params.contains_key("saved_item_ids") { 91 - response["saved_item_ids"] = json!(""); 92 - } 93 - 94 - // handle mark operations from POST body 95 - if let (Some(mark), Some(action), Some(id_str)) = ( 96 - body_params.get("mark"), 97 - body_params.get("as"), 98 - body_params.get("id"), 99 - ) 100 - && let Ok(id) = id_str.parse::<i64>() { 101 - let before: Option<i64> = body_params.get("before").and_then(|v| v.parse().ok()); 102 - debug!(?mark, ?action, id, ?before, "fever mark operation"); 103 - 104 - match (mark.as_str(), action.as_str()) { 105 - ("item", "read") => { 106 - let _ = db::mark_item_read(pool, id).await; 107 - } 108 - ("item", "unread") => { 109 - let _ = db::mark_item_unread(pool, id).await; 110 - } 111 - ("feed", "read") => { 112 - let _ = db::mark_feed_read(pool, id, before).await; 113 - } 114 - ("group", "read") => { 115 - let _ = db::mark_group_read(pool, id, before).await; 116 - } 117 - _ => {} 118 - } 119 - } 120 - 121 - Json(response) 122 - }
+66
src/api/miniflux/auth.rs
··· 1 + use axum::{ 2 + extract::{Request, State}, 3 + http::StatusCode, 4 + middleware::Next, 5 + response::{IntoResponse, Response}, 6 + Json, 7 + }; 8 + use base64::Engine; 9 + 10 + use crate::server::AppState; 11 + use super::types::ErrorResponse; 12 + 13 + /// Middleware that validates Miniflux-style authentication. 14 + /// Accepts either: 15 + /// - `X-Auth-Token: <api_key>` 16 + /// - `Authorization: Basic <base64(username:api_key)>` 17 + pub async fn auth_middleware( 18 + State(state): State<AppState>, 19 + request: Request, 20 + next: Next, 21 + ) -> Response { 22 + let authenticated = check_auth(request.headers(), &state.api_key); 23 + 24 + if !authenticated { 25 + return ( 26 + StatusCode::UNAUTHORIZED, 27 + Json(ErrorResponse { 28 + error_message: "Access Unauthorized".to_string(), 29 + }), 30 + ) 31 + .into_response(); 32 + } 33 + 34 + next.run(request).await 35 + } 36 + 37 + fn check_auth(headers: &axum::http::HeaderMap, api_key: &str) -> bool { 38 + // Check X-Auth-Token header 39 + if let Some(token) = headers.get("X-Auth-Token") { 40 + if let Ok(token_str) = token.to_str() { 41 + if token_str == api_key { 42 + return true; 43 + } 44 + } 45 + } 46 + 47 + // Check Basic auth (password must match api_key) 48 + if let Some(auth) = headers.get("Authorization") { 49 + if let Ok(auth_str) = auth.to_str() { 50 + if let Some(encoded) = auth_str.strip_prefix("Basic ") { 51 + if let Ok(decoded) = base64::engine::general_purpose::STANDARD.decode(encoded) { 52 + if let Ok(decoded_str) = String::from_utf8(decoded) { 53 + // Format: username:password — password must match api_key 54 + if let Some((_user, pass)) = decoded_str.split_once(':') { 55 + if pass == api_key { 56 + return true; 57 + } 58 + } 59 + } 60 + } 61 + } 62 + } 63 + } 64 + 65 + false 66 + }
+631
src/api/miniflux/handlers.rs
··· 1 + use axum::{ 2 + extract::{Path, Query, State}, 3 + http::StatusCode, 4 + response::IntoResponse, 5 + Json, 6 + }; 7 + use std::collections::HashMap; 8 + 9 + use crate::db; 10 + use crate::server::AppState; 11 + use super::types::*; 12 + 13 + // --------------------------------------------------------------------------- 14 + // User 15 + // --------------------------------------------------------------------------- 16 + 17 + pub async fn get_me() -> Json<UserResponse> { 18 + Json(UserResponse::default()) 19 + } 20 + 21 + // --------------------------------------------------------------------------- 22 + // Categories 23 + // --------------------------------------------------------------------------- 24 + 25 + pub async fn get_categories( 26 + State(state): State<AppState>, 27 + Query(params): Query<CategoryQueryParams>, 28 + ) -> Result<Json<Vec<CategoryResponse>>, StatusCode> { 29 + if params.counts.unwrap_or(false) { 30 + let cats = db::get_categories_with_counts(&state.pool) 31 + .await 32 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 33 + Ok(Json(cats.into_iter().map(CategoryResponse::from).collect())) 34 + } else { 35 + let cats = db::get_categories(&state.pool) 36 + .await 37 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 38 + Ok(Json(cats.into_iter().map(CategoryResponse::from).collect())) 39 + } 40 + } 41 + 42 + pub async fn create_category( 43 + State(state): State<AppState>, 44 + Json(req): Json<CreateCategoryRequest>, 45 + ) -> Result<(StatusCode, Json<CategoryResponse>), (StatusCode, Json<ErrorResponse>)> { 46 + if req.title.trim().is_empty() { 47 + return Err(( 48 + StatusCode::BAD_REQUEST, 49 + Json(ErrorResponse { 50 + error_message: "The title is mandatory".to_string(), 51 + }), 52 + )); 53 + } 54 + let cat = db::create_category(&state.pool, req.title.trim()) 55 + .await 56 + .map_err(|_| { 57 + ( 58 + StatusCode::CONFLICT, 59 + Json(ErrorResponse { 60 + error_message: "A category with this title already exists".to_string(), 61 + }), 62 + ) 63 + })?; 64 + Ok((StatusCode::CREATED, Json(CategoryResponse::from(cat)))) 65 + } 66 + 67 + pub async fn update_category( 68 + State(state): State<AppState>, 69 + Path(id): Path<i64>, 70 + Json(req): Json<UpdateCategoryRequest>, 71 + ) -> Result<Json<CategoryResponse>, (StatusCode, Json<ErrorResponse>)> { 72 + if req.title.trim().is_empty() { 73 + return Err(( 74 + StatusCode::BAD_REQUEST, 75 + Json(ErrorResponse { 76 + error_message: "The title is mandatory".to_string(), 77 + }), 78 + )); 79 + } 80 + let updated = db::update_category(&state.pool, id, req.title.trim()) 81 + .await 82 + .map_err(|_| { 83 + ( 84 + StatusCode::INTERNAL_SERVER_ERROR, 85 + Json(ErrorResponse { 86 + error_message: "Unable to update category".to_string(), 87 + }), 88 + ) 89 + })?; 90 + if !updated { 91 + return Err(( 92 + StatusCode::NOT_FOUND, 93 + Json(ErrorResponse { 94 + error_message: "Category not found".to_string(), 95 + }), 96 + )); 97 + } 98 + let cat = db::get_category(&state.pool, id) 99 + .await 100 + .map_err(|_| { 101 + ( 102 + StatusCode::INTERNAL_SERVER_ERROR, 103 + Json(ErrorResponse { 104 + error_message: "Unable to fetch category".to_string(), 105 + }), 106 + ) 107 + })? 108 + .ok_or(( 109 + StatusCode::NOT_FOUND, 110 + Json(ErrorResponse { 111 + error_message: "Category not found".to_string(), 112 + }), 113 + ))?; 114 + Ok(Json(CategoryResponse::from(cat))) 115 + } 116 + 117 + pub async fn delete_category( 118 + State(state): State<AppState>, 119 + Path(id): Path<i64>, 120 + ) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> { 121 + let deleted = db::delete_category(&state.pool, id).await.map_err(|_| { 122 + ( 123 + StatusCode::INTERNAL_SERVER_ERROR, 124 + Json(ErrorResponse { 125 + error_message: "Unable to delete category".to_string(), 126 + }), 127 + ) 128 + })?; 129 + if !deleted { 130 + return Err(( 131 + StatusCode::NOT_FOUND, 132 + Json(ErrorResponse { 133 + error_message: "Category not found".to_string(), 134 + }), 135 + )); 136 + } 137 + Ok(StatusCode::NO_CONTENT) 138 + } 139 + 140 + pub async fn mark_category_entries_as_read( 141 + State(state): State<AppState>, 142 + Path(id): Path<i64>, 143 + ) -> Result<StatusCode, StatusCode> { 144 + db::mark_all_category_entries_read(&state.pool, id) 145 + .await 146 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 147 + Ok(StatusCode::NO_CONTENT) 148 + } 149 + 150 + pub async fn get_category_entries( 151 + State(state): State<AppState>, 152 + Path(id): Path<i64>, 153 + Query(params): Query<EntryQueryParams>, 154 + ) -> Result<Json<EntriesResponse>, StatusCode> { 155 + let mut filter: db::EntryFilter = params.into(); 156 + filter.category_id = Some(id); 157 + let (total, entries) = db::get_entries_filtered(&state.pool, &filter) 158 + .await 159 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 160 + Ok(Json(EntriesResponse { 161 + total, 162 + entries: entries.into_iter().map(EntryResponse::from).collect(), 163 + })) 164 + } 165 + 166 + pub async fn get_category_feeds( 167 + State(state): State<AppState>, 168 + Path(id): Path<i64>, 169 + ) -> Result<Json<Vec<FeedResponse>>, StatusCode> { 170 + let feeds = db::get_category_feeds(&state.pool, id) 171 + .await 172 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 173 + Ok(Json(feeds.into_iter().map(FeedResponse::from).collect())) 174 + } 175 + 176 + // --------------------------------------------------------------------------- 177 + // Feeds 178 + // --------------------------------------------------------------------------- 179 + 180 + pub async fn get_feeds( 181 + State(state): State<AppState>, 182 + ) -> Result<Json<Vec<FeedResponse>>, StatusCode> { 183 + let feeds = db::get_feeds_with_categories(&state.pool) 184 + .await 185 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 186 + Ok(Json(feeds.into_iter().map(FeedResponse::from).collect())) 187 + } 188 + 189 + pub async fn get_feed( 190 + State(state): State<AppState>, 191 + Path(id): Path<i64>, 192 + ) -> Result<Json<FeedResponse>, (StatusCode, Json<ErrorResponse>)> { 193 + let feed = db::get_feed_with_category(&state.pool, id) 194 + .await 195 + .map_err(|_| { 196 + ( 197 + StatusCode::INTERNAL_SERVER_ERROR, 198 + Json(ErrorResponse { 199 + error_message: "Unable to fetch feed".to_string(), 200 + }), 201 + ) 202 + })? 203 + .ok_or(( 204 + StatusCode::NOT_FOUND, 205 + Json(ErrorResponse { 206 + error_message: "Feed not found".to_string(), 207 + }), 208 + ))?; 209 + Ok(Json(FeedResponse::from(feed))) 210 + } 211 + 212 + pub async fn create_feed( 213 + State(state): State<AppState>, 214 + Json(req): Json<CreateFeedRequest>, 215 + ) -> Result<(StatusCode, Json<FeedResponse>), (StatusCode, Json<ErrorResponse>)> { 216 + if req.feed_url.trim().is_empty() { 217 + return Err(( 218 + StatusCode::BAD_REQUEST, 219 + Json(ErrorResponse { 220 + error_message: "The feed URL is mandatory".to_string(), 221 + }), 222 + )); 223 + } 224 + // Verify category exists 225 + if db::get_category(&state.pool, req.category_id).await.map_err(|_| { 226 + ( 227 + StatusCode::INTERNAL_SERVER_ERROR, 228 + Json(ErrorResponse { 229 + error_message: "Unable to verify category".to_string(), 230 + }), 231 + ) 232 + })?.is_none() { 233 + return Err(( 234 + StatusCode::NOT_FOUND, 235 + Json(ErrorResponse { 236 + error_message: "Category not found".to_string(), 237 + }), 238 + )); 239 + } 240 + 241 + let feed_id = db::create_feed(&state.pool, req.feed_url.trim(), req.category_id) 242 + .await 243 + .map_err(|_| { 244 + ( 245 + StatusCode::CONFLICT, 246 + Json(ErrorResponse { 247 + error_message: "This feed already exists".to_string(), 248 + }), 249 + ) 250 + })?; 251 + 252 + // Trigger a fetch for the new feed 253 + if let Some(ref trigger) = state.refresh_trigger { 254 + let _ = trigger.send(Some(feed_id)); 255 + } 256 + 257 + let feed = db::get_feed_with_category(&state.pool, feed_id) 258 + .await 259 + .map_err(|_| { 260 + ( 261 + StatusCode::INTERNAL_SERVER_ERROR, 262 + Json(ErrorResponse { 263 + error_message: "Feed created but unable to fetch details".to_string(), 264 + }), 265 + ) 266 + })? 267 + .ok_or(( 268 + StatusCode::INTERNAL_SERVER_ERROR, 269 + Json(ErrorResponse { 270 + error_message: "Feed created but not found".to_string(), 271 + }), 272 + ))?; 273 + 274 + Ok((StatusCode::CREATED, Json(FeedResponse::from(feed)))) 275 + } 276 + 277 + pub async fn update_feed( 278 + State(state): State<AppState>, 279 + Path(id): Path<i64>, 280 + Json(req): Json<UpdateFeedRequest>, 281 + ) -> Result<Json<FeedResponse>, (StatusCode, Json<ErrorResponse>)> { 282 + let updated = db::update_feed( 283 + &state.pool, 284 + id, 285 + req.title.as_deref(), 286 + req.feed_url.as_deref(), 287 + req.site_url.as_deref(), 288 + req.category_id, 289 + ) 290 + .await 291 + .map_err(|_| { 292 + ( 293 + StatusCode::INTERNAL_SERVER_ERROR, 294 + Json(ErrorResponse { 295 + error_message: "Unable to update feed".to_string(), 296 + }), 297 + ) 298 + })?; 299 + if !updated { 300 + return Err(( 301 + StatusCode::NOT_FOUND, 302 + Json(ErrorResponse { 303 + error_message: "Feed not found".to_string(), 304 + }), 305 + )); 306 + } 307 + let feed = db::get_feed_with_category(&state.pool, id) 308 + .await 309 + .map_err(|_| { 310 + ( 311 + StatusCode::INTERNAL_SERVER_ERROR, 312 + Json(ErrorResponse { 313 + error_message: "Unable to fetch feed".to_string(), 314 + }), 315 + ) 316 + })? 317 + .ok_or(( 318 + StatusCode::NOT_FOUND, 319 + Json(ErrorResponse { 320 + error_message: "Feed not found".to_string(), 321 + }), 322 + ))?; 323 + Ok(Json(FeedResponse::from(feed))) 324 + } 325 + 326 + pub async fn delete_feed( 327 + State(state): State<AppState>, 328 + Path(id): Path<i64>, 329 + ) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> { 330 + let deleted = db::delete_feed(&state.pool, id).await.map_err(|_| { 331 + ( 332 + StatusCode::INTERNAL_SERVER_ERROR, 333 + Json(ErrorResponse { 334 + error_message: "Unable to delete feed".to_string(), 335 + }), 336 + ) 337 + })?; 338 + if !deleted { 339 + return Err(( 340 + StatusCode::NOT_FOUND, 341 + Json(ErrorResponse { 342 + error_message: "Feed not found".to_string(), 343 + }), 344 + )); 345 + } 346 + Ok(StatusCode::NO_CONTENT) 347 + } 348 + 349 + pub async fn get_feed_icon( 350 + State(state): State<AppState>, 351 + Path(id): Path<i64>, 352 + ) -> Result<Json<IconResponse>, (StatusCode, Json<ErrorResponse>)> { 353 + let icon = db::get_icon_by_feed_id(&state.pool, id) 354 + .await 355 + .map_err(|_| { 356 + ( 357 + StatusCode::INTERNAL_SERVER_ERROR, 358 + Json(ErrorResponse { 359 + error_message: "Unable to fetch icon".to_string(), 360 + }), 361 + ) 362 + })? 363 + .ok_or(( 364 + StatusCode::NOT_FOUND, 365 + Json(ErrorResponse { 366 + error_message: "Resource not found".to_string(), 367 + }), 368 + ))?; 369 + Ok(Json(IconResponse::from(icon))) 370 + } 371 + 372 + pub async fn get_feed_entries( 373 + State(state): State<AppState>, 374 + Path(id): Path<i64>, 375 + Query(params): Query<EntryQueryParams>, 376 + ) -> Result<Json<EntriesResponse>, StatusCode> { 377 + let mut filter: db::EntryFilter = params.into(); 378 + filter.feed_id = Some(id); 379 + let (total, entries) = db::get_entries_filtered(&state.pool, &filter) 380 + .await 381 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 382 + Ok(Json(EntriesResponse { 383 + total, 384 + entries: entries.into_iter().map(EntryResponse::from).collect(), 385 + })) 386 + } 387 + 388 + pub async fn mark_feed_entries_as_read( 389 + State(state): State<AppState>, 390 + Path(id): Path<i64>, 391 + ) -> Result<StatusCode, StatusCode> { 392 + db::mark_all_feed_entries_read(&state.pool, id) 393 + .await 394 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 395 + Ok(StatusCode::NO_CONTENT) 396 + } 397 + 398 + pub async fn refresh_feed( 399 + State(state): State<AppState>, 400 + Path(id): Path<i64>, 401 + ) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> { 402 + if !db::feed_exists(&state.pool, id).await.map_err(|_| { 403 + ( 404 + StatusCode::INTERNAL_SERVER_ERROR, 405 + Json(ErrorResponse { 406 + error_message: "Unable to check feed".to_string(), 407 + }), 408 + ) 409 + })? { 410 + return Err(( 411 + StatusCode::NOT_FOUND, 412 + Json(ErrorResponse { 413 + error_message: "Feed not found".to_string(), 414 + }), 415 + )); 416 + } 417 + if let Some(ref trigger) = state.refresh_trigger { 418 + let _ = trigger.send(Some(id)); 419 + } 420 + Ok(StatusCode::NO_CONTENT) 421 + } 422 + 423 + pub async fn refresh_all_feeds(State(state): State<AppState>) -> StatusCode { 424 + if let Some(ref trigger) = state.refresh_trigger { 425 + let _ = trigger.send(None); // None = refresh all 426 + } 427 + StatusCode::NO_CONTENT 428 + } 429 + 430 + // --------------------------------------------------------------------------- 431 + // Entries 432 + // --------------------------------------------------------------------------- 433 + 434 + pub async fn get_entries( 435 + State(state): State<AppState>, 436 + Query(params): Query<EntryQueryParams>, 437 + ) -> Result<Json<EntriesResponse>, StatusCode> { 438 + let filter: db::EntryFilter = params.into(); 439 + let (total, entries) = db::get_entries_filtered(&state.pool, &filter) 440 + .await 441 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 442 + Ok(Json(EntriesResponse { 443 + total, 444 + entries: entries.into_iter().map(EntryResponse::from).collect(), 445 + })) 446 + } 447 + 448 + pub async fn get_entry( 449 + State(state): State<AppState>, 450 + Path(id): Path<i64>, 451 + ) -> Result<Json<EntryResponse>, (StatusCode, Json<ErrorResponse>)> { 452 + let entry = db::get_entry(&state.pool, id) 453 + .await 454 + .map_err(|_| { 455 + ( 456 + StatusCode::INTERNAL_SERVER_ERROR, 457 + Json(ErrorResponse { 458 + error_message: "Unable to fetch entry".to_string(), 459 + }), 460 + ) 461 + })? 462 + .ok_or(( 463 + StatusCode::NOT_FOUND, 464 + Json(ErrorResponse { 465 + error_message: "Entry not found".to_string(), 466 + }), 467 + ))?; 468 + Ok(Json(EntryResponse::from(entry))) 469 + } 470 + 471 + pub async fn update_entries( 472 + State(state): State<AppState>, 473 + Json(req): Json<UpdateEntriesRequest>, 474 + ) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> { 475 + if req.status != "read" && req.status != "unread" { 476 + return Err(( 477 + StatusCode::BAD_REQUEST, 478 + Json(ErrorResponse { 479 + error_message: "Invalid status; must be 'read' or 'unread'".to_string(), 480 + }), 481 + )); 482 + } 483 + db::update_entries_status(&state.pool, &req.entry_ids, &req.status) 484 + .await 485 + .map_err(|_| { 486 + ( 487 + StatusCode::INTERNAL_SERVER_ERROR, 488 + Json(ErrorResponse { 489 + error_message: "Unable to update entries".to_string(), 490 + }), 491 + ) 492 + })?; 493 + Ok(StatusCode::NO_CONTENT) 494 + } 495 + 496 + pub async fn toggle_bookmark( 497 + State(state): State<AppState>, 498 + Path(id): Path<i64>, 499 + ) -> Result<StatusCode, (StatusCode, Json<ErrorResponse>)> { 500 + db::toggle_entry_starred(&state.pool, id) 501 + .await 502 + .map_err(|_| { 503 + ( 504 + StatusCode::INTERNAL_SERVER_ERROR, 505 + Json(ErrorResponse { 506 + error_message: "Unable to toggle bookmark".to_string(), 507 + }), 508 + ) 509 + })?; 510 + Ok(StatusCode::NO_CONTENT) 511 + } 512 + 513 + // --------------------------------------------------------------------------- 514 + // Icons 515 + // --------------------------------------------------------------------------- 516 + 517 + pub async fn get_icon( 518 + State(state): State<AppState>, 519 + Path(id): Path<i64>, 520 + ) -> Result<Json<IconResponse>, (StatusCode, Json<ErrorResponse>)> { 521 + let icon = db::get_icon_by_id(&state.pool, id) 522 + .await 523 + .map_err(|_| { 524 + ( 525 + StatusCode::INTERNAL_SERVER_ERROR, 526 + Json(ErrorResponse { 527 + error_message: "Unable to fetch icon".to_string(), 528 + }), 529 + ) 530 + })? 531 + .ok_or(( 532 + StatusCode::NOT_FOUND, 533 + Json(ErrorResponse { 534 + error_message: "Resource not found".to_string(), 535 + }), 536 + ))?; 537 + Ok(Json(IconResponse::from(icon))) 538 + } 539 + 540 + // --------------------------------------------------------------------------- 541 + // Counters 542 + // --------------------------------------------------------------------------- 543 + 544 + pub async fn get_feed_counters( 545 + State(state): State<AppState>, 546 + ) -> Result<Json<FeedCountersResponse>, StatusCode> { 547 + let (reads, unreads) = db::get_feed_counters(&state.pool) 548 + .await 549 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 550 + // Miniflux keys are feed IDs as strings 551 + let reads: HashMap<String, i64> = reads.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); 552 + let unreads: HashMap<String, i64> = 553 + unreads.into_iter().map(|(k, v)| (k.to_string(), v)).collect(); 554 + Ok(Json(FeedCountersResponse { reads, unreads })) 555 + } 556 + 557 + // --------------------------------------------------------------------------- 558 + // OPML Export 559 + // --------------------------------------------------------------------------- 560 + 561 + pub async fn export_opml( 562 + State(state): State<AppState>, 563 + ) -> Result<impl IntoResponse, StatusCode> { 564 + let feeds = db::get_feeds_for_export(&state.pool) 565 + .await 566 + .map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?; 567 + 568 + // Group by category 569 + let mut categories: std::collections::BTreeMap<String, Vec<&db::FeedExportRow>> = 570 + std::collections::BTreeMap::new(); 571 + for feed in &feeds { 572 + categories 573 + .entry(feed.category_name.clone()) 574 + .or_default() 575 + .push(feed); 576 + } 577 + 578 + let mut opml = String::from( 579 + r#"<?xml version="1.0" encoding="UTF-8"?> 580 + <opml version="2.0"> 581 + <head><title>slurp feeds</title></head> 582 + <body> 583 + "#, 584 + ); 585 + for (cat, cat_feeds) in &categories { 586 + opml.push_str(&format!( 587 + " <outline text=\"{}\">\n", 588 + xml_escape(cat) 589 + )); 590 + for f in cat_feeds { 591 + opml.push_str(&format!( 592 + " <outline type=\"rss\" text=\"{}\" xmlUrl=\"{}\" htmlUrl=\"{}\"/>\n", 593 + xml_escape(&f.feed_title), 594 + xml_escape(&f.feed_url), 595 + xml_escape(&f.site_url), 596 + )); 597 + } 598 + opml.push_str(" </outline>\n"); 599 + } 600 + opml.push_str(" </body>\n</opml>\n"); 601 + 602 + Ok(( 603 + [( 604 + axum::http::header::CONTENT_TYPE, 605 + "application/xml; charset=utf-8", 606 + )], 607 + opml, 608 + )) 609 + } 610 + 611 + fn xml_escape(s: &str) -> String { 612 + s.replace('&', "&amp;") 613 + .replace('<', "&lt;") 614 + .replace('>', "&gt;") 615 + .replace('"', "&quot;") 616 + .replace('\'', "&apos;") 617 + } 618 + 619 + // --------------------------------------------------------------------------- 620 + // Health / Version 621 + // --------------------------------------------------------------------------- 622 + 623 + pub async fn healthcheck() -> &'static str { 624 + "OK" 625 + } 626 + 627 + pub async fn version() -> Json<VersionResponse> { 628 + Json(VersionResponse { 629 + version: env!("CARGO_PKG_VERSION").to_string(), 630 + }) 631 + }
+71
src/api/miniflux/mod.rs
··· 1 + pub mod auth; 2 + pub mod handlers; 3 + pub mod types; 4 + 5 + use axum::{ 6 + routing::{get, put}, 7 + Router, 8 + }; 9 + 10 + use crate::server::AppState; 11 + 12 + /// Build the `/v1` router (auth middleware is applied by the caller). 13 + pub fn v1_router() -> Router<AppState> { 14 + Router::new() 15 + // User 16 + .route("/me", get(handlers::get_me)) 17 + // Categories 18 + .route( 19 + "/categories", 20 + get(handlers::get_categories).post(handlers::create_category), 21 + ) 22 + .route( 23 + "/categories/{id}", 24 + put(handlers::update_category).delete(handlers::delete_category), 25 + ) 26 + .route( 27 + "/categories/{id}/mark-all-as-read", 28 + put(handlers::mark_category_entries_as_read), 29 + ) 30 + .route( 31 + "/categories/{id}/entries", 32 + get(handlers::get_category_entries), 33 + ) 34 + .route( 35 + "/categories/{id}/feeds", 36 + get(handlers::get_category_feeds), 37 + ) 38 + // Feeds 39 + .route( 40 + "/feeds", 41 + get(handlers::get_feeds).post(handlers::create_feed), 42 + ) 43 + .route( 44 + "/feeds/{id}", 45 + get(handlers::get_feed) 46 + .put(handlers::update_feed) 47 + .delete(handlers::delete_feed), 48 + ) 49 + .route("/feeds/{id}/icon", get(handlers::get_feed_icon)) 50 + .route("/feeds/{id}/entries", get(handlers::get_feed_entries)) 51 + .route( 52 + "/feeds/{id}/mark-all-as-read", 53 + put(handlers::mark_feed_entries_as_read), 54 + ) 55 + .route("/feeds/{id}/refresh", put(handlers::refresh_feed)) 56 + .route("/feeds/refresh", put(handlers::refresh_all_feeds)) 57 + .route("/feeds/counters", get(handlers::get_feed_counters)) 58 + // Entries 59 + .route( 60 + "/entries", 61 + get(handlers::get_entries).put(handlers::update_entries), 62 + ) 63 + .route("/entries/{id}", get(handlers::get_entry)) 64 + .route("/entries/{id}/bookmark", put(handlers::toggle_bookmark)) 65 + // Icons 66 + .route("/icons/{id}", get(handlers::get_icon)) 67 + // OPML 68 + .route("/export", get(handlers::export_opml)) 69 + // Version 70 + .route("/version", get(handlers::version)) 71 + }
+349
src/api/miniflux/types.rs
··· 1 + use serde::{Deserialize, Serialize}; 2 + use std::collections::HashMap; 3 + 4 + use crate::db; 5 + 6 + // --------------------------------------------------------------------------- 7 + // Response types 8 + // --------------------------------------------------------------------------- 9 + 10 + #[derive(Debug, Serialize)] 11 + pub struct UserResponse { 12 + pub id: i64, 13 + pub username: String, 14 + pub is_admin: bool, 15 + pub theme: String, 16 + pub language: String, 17 + pub timezone: String, 18 + pub entry_sorting_direction: String, 19 + pub entries_per_page: i64, 20 + } 21 + 22 + impl Default for UserResponse { 23 + fn default() -> Self { 24 + Self { 25 + id: 1, 26 + username: "admin".to_string(), 27 + is_admin: true, 28 + theme: "light_serif".to_string(), 29 + language: "en_US".to_string(), 30 + timezone: "UTC".to_string(), 31 + entry_sorting_direction: "desc".to_string(), 32 + entries_per_page: 100, 33 + } 34 + } 35 + } 36 + 37 + #[derive(Debug, Serialize)] 38 + pub struct CategoryResponse { 39 + pub id: i64, 40 + pub title: String, 41 + pub user_id: i64, 42 + #[serde(skip_serializing_if = "Option::is_none")] 43 + pub feed_count: Option<i64>, 44 + #[serde(skip_serializing_if = "Option::is_none")] 45 + pub total_unread: Option<i64>, 46 + } 47 + 48 + impl From<db::Category> for CategoryResponse { 49 + fn from(c: db::Category) -> Self { 50 + Self { 51 + id: c.id, 52 + title: c.title, 53 + user_id: 1, 54 + feed_count: None, 55 + total_unread: None, 56 + } 57 + } 58 + } 59 + 60 + impl From<db::CategoryWithCounts> for CategoryResponse { 61 + fn from(c: db::CategoryWithCounts) -> Self { 62 + Self { 63 + id: c.id, 64 + title: c.title, 65 + user_id: 1, 66 + feed_count: Some(c.feed_count), 67 + total_unread: Some(c.total_unread), 68 + } 69 + } 70 + } 71 + 72 + #[derive(Debug, Serialize)] 73 + pub struct FeedResponse { 74 + pub id: i64, 75 + pub user_id: i64, 76 + pub feed_url: String, 77 + pub site_url: String, 78 + pub title: String, 79 + pub checked_at: String, 80 + pub parsing_error_count: i64, 81 + pub parsing_error_message: String, 82 + pub category: CategoryResponse, 83 + #[serde(skip_serializing_if = "Option::is_none")] 84 + pub icon: Option<FeedIconRef>, 85 + } 86 + 87 + #[derive(Debug, Serialize)] 88 + pub struct FeedIconRef { 89 + pub feed_id: i64, 90 + pub icon_id: i64, 91 + } 92 + 93 + impl From<db::FeedWithCategory> for FeedResponse { 94 + fn from(f: db::FeedWithCategory) -> Self { 95 + let checked_at = if let Some(ts) = f.last_fetched_at { 96 + chrono::DateTime::from_timestamp(ts, 0) 97 + .map(|dt| dt.to_rfc3339()) 98 + .unwrap_or_default() 99 + } else { 100 + String::new() 101 + }; 102 + let icon = f.favicon_id.map(|icon_id| FeedIconRef { 103 + feed_id: f.id, 104 + icon_id, 105 + }); 106 + Self { 107 + id: f.id, 108 + user_id: 1, 109 + feed_url: f.url, 110 + site_url: f.site_url, 111 + title: f.title, 112 + checked_at, 113 + parsing_error_count: 0, 114 + parsing_error_message: String::new(), 115 + category: CategoryResponse { 116 + id: f.category_id, 117 + title: f.category_title, 118 + user_id: 1, 119 + feed_count: None, 120 + total_unread: None, 121 + }, 122 + icon, 123 + } 124 + } 125 + } 126 + 127 + #[derive(Debug, Serialize)] 128 + pub struct EntryResponse { 129 + pub id: i64, 130 + pub user_id: i64, 131 + pub feed_id: i64, 132 + pub status: String, 133 + pub hash: String, 134 + pub title: String, 135 + pub url: String, 136 + pub author: String, 137 + pub content: String, 138 + pub published_at: String, 139 + pub created_at: String, 140 + pub changed_at: String, 141 + pub starred: bool, 142 + pub reading_time: i64, 143 + pub feed: EntryFeedRef, 144 + } 145 + 146 + #[derive(Debug, Serialize)] 147 + pub struct EntryFeedRef { 148 + pub id: i64, 149 + pub user_id: i64, 150 + pub feed_url: String, 151 + pub site_url: String, 152 + pub title: String, 153 + pub category: CategoryResponse, 154 + } 155 + 156 + impl From<db::EntryRow> for EntryResponse { 157 + fn from(e: db::EntryRow) -> Self { 158 + let status = if e.is_read == 1 { "read" } else { "unread" }.to_string(); 159 + let published_at = chrono::DateTime::from_timestamp( 160 + e.published_at.unwrap_or(e.created_at), 161 + 0, 162 + ) 163 + .map(|dt| dt.to_rfc3339()) 164 + .unwrap_or_default(); 165 + let created_at = chrono::DateTime::from_timestamp(e.created_at, 0) 166 + .map(|dt| dt.to_rfc3339()) 167 + .unwrap_or_default(); 168 + let changed_at = chrono::DateTime::from_timestamp(e.changed_at, 0) 169 + .map(|dt| dt.to_rfc3339()) 170 + .unwrap_or_default(); 171 + 172 + // Simple reading time estimate: ~200 words/min 173 + let word_count = e.content.split_whitespace().count() as i64; 174 + let reading_time = (word_count / 200).max(1); 175 + 176 + Self { 177 + id: e.id, 178 + user_id: 1, 179 + feed_id: e.feed_id, 180 + status, 181 + hash: format!("{:x}", md5::compute(format!("{}:{}", e.feed_id, e.url))), 182 + title: e.title, 183 + url: e.url, 184 + author: e.author, 185 + content: e.content, 186 + published_at, 187 + created_at, 188 + changed_at, 189 + starred: e.is_starred == 1, 190 + reading_time, 191 + feed: EntryFeedRef { 192 + id: e.feed_id, 193 + user_id: 1, 194 + feed_url: e.feed_url, 195 + site_url: e.feed_site_url, 196 + title: e.feed_title, 197 + category: CategoryResponse { 198 + id: e.category_id, 199 + title: e.category_title, 200 + user_id: 1, 201 + feed_count: None, 202 + total_unread: None, 203 + }, 204 + }, 205 + } 206 + } 207 + } 208 + 209 + #[derive(Debug, Serialize)] 210 + pub struct EntriesResponse { 211 + pub total: i64, 212 + pub entries: Vec<EntryResponse>, 213 + } 214 + 215 + #[derive(Debug, Serialize)] 216 + pub struct IconResponse { 217 + pub id: i64, 218 + pub data: String, 219 + pub mime_type: String, 220 + } 221 + 222 + impl From<db::IconRow> for IconResponse { 223 + fn from(icon: db::IconRow) -> Self { 224 + // Data stored as "mime;base64,DATA" 225 + let (mime, data) = if let Some(pos) = icon.data.find(";base64,") { 226 + ( 227 + icon.data[..pos].to_string(), 228 + icon.data[pos + 8..].to_string(), 229 + ) 230 + } else { 231 + ("image/x-icon".to_string(), icon.data) 232 + }; 233 + Self { 234 + id: icon.id, 235 + data, 236 + mime_type: mime, 237 + } 238 + } 239 + } 240 + 241 + #[derive(Debug, Serialize)] 242 + pub struct FeedCountersResponse { 243 + pub reads: HashMap<String, i64>, 244 + pub unreads: HashMap<String, i64>, 245 + } 246 + 247 + #[derive(Debug, Serialize)] 248 + pub struct VersionResponse { 249 + pub version: String, 250 + } 251 + 252 + #[derive(Debug, Serialize)] 253 + pub struct ErrorResponse { 254 + pub error_message: String, 255 + } 256 + 257 + // --------------------------------------------------------------------------- 258 + // Request types 259 + // --------------------------------------------------------------------------- 260 + 261 + #[derive(Debug, Deserialize)] 262 + pub struct CreateCategoryRequest { 263 + pub title: String, 264 + } 265 + 266 + #[derive(Debug, Deserialize)] 267 + pub struct UpdateCategoryRequest { 268 + pub title: String, 269 + } 270 + 271 + #[derive(Debug, Deserialize)] 272 + pub struct CreateFeedRequest { 273 + pub feed_url: String, 274 + pub category_id: i64, 275 + } 276 + 277 + #[derive(Debug, Deserialize)] 278 + pub struct UpdateFeedRequest { 279 + #[serde(default)] 280 + pub title: Option<String>, 281 + #[serde(default)] 282 + pub feed_url: Option<String>, 283 + #[serde(default)] 284 + pub site_url: Option<String>, 285 + #[serde(default)] 286 + pub category_id: Option<i64>, 287 + } 288 + 289 + #[derive(Debug, Deserialize)] 290 + pub struct UpdateEntriesRequest { 291 + pub entry_ids: Vec<i64>, 292 + pub status: String, 293 + } 294 + 295 + #[derive(Debug, Deserialize)] 296 + pub struct EntryQueryParams { 297 + #[serde(default)] 298 + pub status: Option<String>, 299 + #[serde(default)] 300 + pub feed_id: Option<i64>, 301 + #[serde(default)] 302 + pub category_id: Option<i64>, 303 + #[serde(default)] 304 + pub starred: Option<bool>, 305 + #[serde(default)] 306 + pub search: Option<String>, 307 + #[serde(default)] 308 + pub after: Option<i64>, 309 + #[serde(default)] 310 + pub after_entry_id: Option<i64>, 311 + #[serde(default)] 312 + pub before: Option<i64>, 313 + #[serde(default)] 314 + pub before_entry_id: Option<i64>, 315 + #[serde(default)] 316 + pub limit: Option<i64>, 317 + #[serde(default)] 318 + pub offset: Option<i64>, 319 + #[serde(default)] 320 + pub order: Option<String>, 321 + #[serde(default)] 322 + pub direction: Option<String>, 323 + } 324 + 325 + impl From<EntryQueryParams> for db::EntryFilter { 326 + fn from(p: EntryQueryParams) -> Self { 327 + Self { 328 + status: p.status, 329 + feed_id: p.feed_id, 330 + category_id: p.category_id, 331 + starred: p.starred, 332 + search: p.search, 333 + after: p.after, 334 + after_entry_id: p.after_entry_id, 335 + before: p.before, 336 + before_entry_id: p.before_entry_id, 337 + limit: p.limit, 338 + offset: p.offset, 339 + order: p.order, 340 + direction: p.direction, 341 + } 342 + } 343 + } 344 + 345 + #[derive(Debug, Deserialize)] 346 + pub struct CategoryQueryParams { 347 + #[serde(default)] 348 + pub counts: Option<bool>, 349 + }
+1 -1
src/api/mod.rs
··· 1 - pub mod fever; 1 + pub mod miniflux;
+14 -12
src/config.rs
··· 1 1 use anyhow::{Context, Result, bail}; 2 2 use serde::Deserialize; 3 - use std::collections::HashSet; 4 3 use std::path::Path; 5 4 6 5 #[derive(Debug, Deserialize)] ··· 8 7 pub server: ServerConfig, 9 8 pub database: DatabaseConfig, 10 9 pub fetcher: FetcherConfig, 10 + /// Legacy bootstrap: only used if DB is empty on first run. 11 11 #[serde(default)] 12 12 pub groups: Vec<GroupConfig>, 13 13 #[serde(default)] ··· 17 17 #[derive(Debug, Deserialize)] 18 18 pub struct ServerConfig { 19 19 pub bind: String, 20 + /// Used as Miniflux API token (X-Auth-Token / Basic auth password). 20 21 pub api_key: String, 21 22 } 22 23 ··· 55 56 if self.server.api_key.is_empty() { 56 57 bail!("server.api_key must not be empty"); 57 58 } 58 - 59 - let group_names: HashSet<&str> = self.groups.iter().map(|g| g.name.as_str()).collect(); 60 - 61 - for feed in &self.feeds { 62 - if !group_names.contains(feed.group.as_str()) { 63 - bail!( 64 - "feed '{}' references unknown group '{}'", 65 - feed.url, 66 - feed.group 67 - ); 59 + // Validate legacy bootstrap references (only matters if sections present) 60 + if !self.feeds.is_empty() { 61 + let group_names: std::collections::HashSet<&str> = 62 + self.groups.iter().map(|g| g.name.as_str()).collect(); 63 + for feed in &self.feeds { 64 + if !group_names.contains(feed.group.as_str()) { 65 + bail!( 66 + "feed '{}' references unknown group '{}'", 67 + feed.url, 68 + feed.group 69 + ); 70 + } 68 71 } 69 72 } 70 - 71 73 Ok(()) 72 74 } 73 75 }
+509 -187
src/db.rs
··· 1 1 use anyhow::{Context, Result}; 2 2 use sqlx::sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions}; 3 3 use sqlx::SqlitePool; 4 + use std::collections::HashMap; 4 5 use std::str::FromStr; 5 6 6 7 use crate::config::Config; ··· 25 26 Ok(pool) 26 27 } 27 28 28 - pub async fn sync_config(pool: &SqlitePool, config: &Config) -> Result<()> { 29 - let config_group_names: Vec<&str> = config.groups.iter().map(|g| g.name.as_str()).collect(); 29 + /// One-time legacy bootstrap: if the DB has zero categories/feeds and the config 30 + /// contains legacy [[groups]]/[[feeds]] sections, import them. Never deletes. 31 + pub async fn bootstrap_from_legacy_config_if_empty(pool: &SqlitePool, config: &Config) -> Result<()> { 32 + if config.groups.is_empty() && config.feeds.is_empty() { 33 + return Ok(()); 34 + } 30 35 31 - // remove groups not in config (CASCADE deletes their feeds and items) 32 - let existing_groups: Vec<(i64, String)> = sqlx::query_as("SELECT id, name FROM groups") 33 - .fetch_all(pool) 36 + let (group_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM groups") 37 + .fetch_one(pool) 38 + .await?; 39 + let (feed_count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM feeds") 40 + .fetch_one(pool) 34 41 .await?; 35 42 36 - for (id, name) in &existing_groups { 37 - if !config_group_names.contains(&name.as_str()) { 38 - sqlx::query("DELETE FROM groups WHERE id = ?") 39 - .bind(id) 40 - .execute(pool) 41 - .await?; 42 - } 43 + if group_count > 0 || feed_count > 0 { 44 + tracing::info!("DB already has data, skipping legacy config bootstrap"); 45 + return Ok(()); 43 46 } 44 47 45 - // insert missing groups 48 + tracing::info!("DB empty, bootstrapping from legacy config sections"); 49 + 46 50 for group in &config.groups { 47 51 sqlx::query("INSERT OR IGNORE INTO groups (name) VALUES (?)") 48 52 .bind(&group.name) ··· 50 54 .await?; 51 55 } 52 56 53 - // remove feeds not in config 54 - let config_feed_urls: Vec<&str> = config.feeds.iter().map(|f| f.url.as_str()).collect(); 55 - 56 - let existing_feeds: Vec<(i64, String)> = sqlx::query_as("SELECT id, url FROM feeds") 57 - .fetch_all(pool) 58 - .await?; 59 - 60 - for (id, url) in &existing_feeds { 61 - if !config_feed_urls.contains(&url.as_str()) { 62 - sqlx::query("DELETE FROM feeds WHERE id = ?") 63 - .bind(id) 64 - .execute(pool) 65 - .await?; 66 - } 67 - } 68 - 69 - // insert missing feeds, updating group_id if feed already exists 70 57 for feed in &config.feeds { 71 58 let group_id: (i64,) = sqlx::query_as("SELECT id FROM groups WHERE name = ?") 72 59 .bind(&feed.group) 73 60 .fetch_one(pool) 74 61 .await?; 75 62 76 - sqlx::query( 77 - "INSERT INTO feeds (url, group_id) VALUES (?, ?) ON CONFLICT(url) DO UPDATE SET group_id = excluded.group_id", 78 - ) 79 - .bind(&feed.url) 80 - .bind(group_id.0) 81 - .execute(pool) 82 - .await?; 63 + sqlx::query("INSERT OR IGNORE INTO feeds (url, group_id) VALUES (?, ?)") 64 + .bind(&feed.url) 65 + .bind(group_id.0) 66 + .execute(pool) 67 + .await?; 83 68 } 84 69 85 70 Ok(()) 86 71 } 87 72 88 - // -- Fever API query types -- 73 + // --------------------------------------------------------------------------- 74 + // Categories (groups table) 75 + // --------------------------------------------------------------------------- 89 76 90 - #[derive(Debug, serde::Serialize, sqlx::FromRow)] 91 - pub struct Group { 77 + #[derive(Debug, Clone, serde::Serialize, sqlx::FromRow)] 78 + pub struct Category { 92 79 pub id: i64, 93 80 #[sqlx(rename = "name")] 94 81 pub title: String, 95 82 } 96 83 97 - #[derive(Debug, serde::Serialize, sqlx::FromRow)] 98 - pub struct FeedGroup { 99 - pub group_id: i64, 100 - pub feed_ids: String, 84 + #[derive(Debug, serde::Serialize)] 85 + pub struct CategoryWithCounts { 86 + pub id: i64, 87 + pub title: String, 88 + pub feed_count: i64, 89 + pub total_unread: i64, 90 + } 91 + 92 + pub async fn get_categories(pool: &SqlitePool) -> Result<Vec<Category>> { 93 + let rows = sqlx::query_as::<_, Category>("SELECT id, name FROM groups ORDER BY name") 94 + .fetch_all(pool) 95 + .await?; 96 + Ok(rows) 101 97 } 102 98 103 - #[derive(Debug, serde::Serialize, sqlx::FromRow)] 104 - pub struct Feed { 99 + pub async fn get_categories_with_counts(pool: &SqlitePool) -> Result<Vec<CategoryWithCounts>> { 100 + let rows: Vec<(i64, String, i64, i64)> = sqlx::query_as( 101 + "SELECT g.id, g.name, \ 102 + (SELECT COUNT(*) FROM feeds WHERE group_id = g.id) AS feed_count, \ 103 + (SELECT COUNT(*) FROM items i JOIN feeds f ON i.feed_id = f.id WHERE f.group_id = g.id AND i.is_read = 0) AS total_unread \ 104 + FROM groups g ORDER BY g.name", 105 + ) 106 + .fetch_all(pool) 107 + .await?; 108 + Ok(rows 109 + .into_iter() 110 + .map(|(id, title, feed_count, total_unread)| CategoryWithCounts { 111 + id, 112 + title, 113 + feed_count, 114 + total_unread, 115 + }) 116 + .collect()) 117 + } 118 + 119 + pub async fn get_category(pool: &SqlitePool, id: i64) -> Result<Option<Category>> { 120 + let row = sqlx::query_as::<_, Category>("SELECT id, name FROM groups WHERE id = ?") 121 + .bind(id) 122 + .fetch_optional(pool) 123 + .await?; 124 + Ok(row) 125 + } 126 + 127 + pub async fn create_category(pool: &SqlitePool, title: &str) -> Result<Category> { 128 + let result = sqlx::query("INSERT INTO groups (name) VALUES (?)") 129 + .bind(title) 130 + .execute(pool) 131 + .await?; 132 + Ok(Category { 133 + id: result.last_insert_rowid(), 134 + title: title.to_string(), 135 + }) 136 + } 137 + 138 + pub async fn update_category(pool: &SqlitePool, id: i64, title: &str) -> Result<bool> { 139 + let result = sqlx::query("UPDATE groups SET name = ? WHERE id = ?") 140 + .bind(title) 141 + .bind(id) 142 + .execute(pool) 143 + .await?; 144 + Ok(result.rows_affected() > 0) 145 + } 146 + 147 + pub async fn delete_category(pool: &SqlitePool, id: i64) -> Result<bool> { 148 + let result = sqlx::query("DELETE FROM groups WHERE id = ?") 149 + .bind(id) 150 + .execute(pool) 151 + .await?; 152 + Ok(result.rows_affected() > 0) 153 + } 154 + 155 + pub async fn mark_all_category_entries_read(pool: &SqlitePool, category_id: i64) -> Result<()> { 156 + sqlx::query( 157 + "UPDATE items SET is_read = 1, changed_at = unixepoch() \ 158 + WHERE is_read = 0 AND feed_id IN (SELECT id FROM feeds WHERE group_id = ?)", 159 + ) 160 + .bind(category_id) 161 + .execute(pool) 162 + .await?; 163 + Ok(()) 164 + } 165 + 166 + // --------------------------------------------------------------------------- 167 + // Feeds 168 + // --------------------------------------------------------------------------- 169 + 170 + #[allow(dead_code)] 171 + #[derive(Debug, Clone, sqlx::FromRow)] 172 + pub struct FeedRow { 105 173 pub id: i64, 106 - pub favicon_id: i64, 174 + pub url: String, 107 175 pub title: String, 108 - pub url: String, 109 176 pub site_url: String, 110 - #[sqlx(skip)] 111 - pub is_spark: i32, 112 - #[sqlx(rename = "last_fetched_at")] 113 - pub last_updated_on_time: i64, 177 + pub favicon_id: Option<i64>, 178 + pub group_id: i64, 179 + pub last_fetched_at: Option<i64>, 114 180 } 115 181 116 - #[derive(Debug, serde::Serialize, sqlx::FromRow)] 117 - pub struct Item { 182 + /// Full feed info for API responses. 183 + #[allow(dead_code)] 184 + #[derive(Debug, Clone, sqlx::FromRow)] 185 + pub struct FeedWithCategory { 118 186 pub id: i64, 119 - pub feed_id: i64, 120 - pub title: String, 121 - pub author: String, 122 - #[sqlx(rename = "content")] 123 - pub html: String, 124 187 pub url: String, 125 - #[sqlx(skip)] 126 - pub is_saved: i32, 127 - pub is_read: i32, 128 - #[sqlx(rename = "ts")] 129 - pub created_on_time: i64, 188 + pub title: String, 189 + pub site_url: String, 190 + pub favicon_id: Option<i64>, 191 + pub group_id: i64, 192 + pub last_fetched_at: Option<i64>, 193 + pub category_id: i64, 194 + pub category_title: String, 130 195 } 131 196 132 - #[derive(Debug, serde::Serialize, sqlx::FromRow)] 133 - pub struct Favicon { 134 - pub id: i64, 135 - pub data: String, 197 + pub async fn get_feeds_with_categories(pool: &SqlitePool) -> Result<Vec<FeedWithCategory>> { 198 + let rows = sqlx::query_as::<_, FeedWithCategory>( 199 + "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \ 200 + g.id AS category_id, g.name AS category_title \ 201 + FROM feeds f JOIN groups g ON f.group_id = g.id ORDER BY f.title", 202 + ) 203 + .fetch_all(pool) 204 + .await?; 205 + Ok(rows) 136 206 } 137 207 138 - pub async fn get_groups(pool: &SqlitePool) -> Result<Vec<Group>> { 139 - let groups = sqlx::query_as::<_, Group>("SELECT id, name FROM groups") 140 - .fetch_all(pool) 141 - .await?; 142 - Ok(groups) 208 + pub async fn get_feed_with_category(pool: &SqlitePool, id: i64) -> Result<Option<FeedWithCategory>> { 209 + let row = sqlx::query_as::<_, FeedWithCategory>( 210 + "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \ 211 + g.id AS category_id, g.name AS category_title \ 212 + FROM feeds f JOIN groups g ON f.group_id = g.id WHERE f.id = ?", 213 + ) 214 + .bind(id) 215 + .fetch_optional(pool) 216 + .await?; 217 + Ok(row) 143 218 } 144 219 145 - pub async fn get_feeds_groups(pool: &SqlitePool) -> Result<Vec<FeedGroup>> { 146 - let feed_groups = sqlx::query_as::<_, FeedGroup>( 147 - "SELECT group_id, GROUP_CONCAT(id) as feed_ids FROM feeds GROUP BY group_id", 220 + pub async fn get_category_feeds(pool: &SqlitePool, category_id: i64) -> Result<Vec<FeedWithCategory>> { 221 + let rows = sqlx::query_as::<_, FeedWithCategory>( 222 + "SELECT f.id, f.url, f.title, f.site_url, f.favicon_id, f.group_id, f.last_fetched_at, \ 223 + g.id AS category_id, g.name AS category_title \ 224 + FROM feeds f JOIN groups g ON f.group_id = g.id WHERE f.group_id = ? ORDER BY f.title", 148 225 ) 226 + .bind(category_id) 149 227 .fetch_all(pool) 150 228 .await?; 151 - Ok(feed_groups) 229 + Ok(rows) 230 + } 231 + 232 + pub async fn create_feed(pool: &SqlitePool, feed_url: &str, category_id: i64) -> Result<i64> { 233 + let result = sqlx::query("INSERT INTO feeds (url, group_id) VALUES (?, ?)") 234 + .bind(feed_url) 235 + .bind(category_id) 236 + .execute(pool) 237 + .await?; 238 + Ok(result.last_insert_rowid()) 152 239 } 153 240 154 - pub async fn get_feeds(pool: &SqlitePool) -> Result<Vec<Feed>> { 155 - let feeds = sqlx::query_as::<_, Feed>( 156 - "SELECT id, COALESCE(favicon_id, 0) as favicon_id, title, url, site_url, \ 157 - COALESCE(last_fetched_at, 0) as last_fetched_at FROM feeds", 241 + pub async fn update_feed( 242 + pool: &SqlitePool, 243 + id: i64, 244 + title: Option<&str>, 245 + feed_url: Option<&str>, 246 + site_url: Option<&str>, 247 + category_id: Option<i64>, 248 + ) -> Result<bool> { 249 + // Build SET clauses dynamically 250 + let mut sets = Vec::new(); 251 + if title.is_some() { 252 + sets.push("title = ?"); 253 + } 254 + if feed_url.is_some() { 255 + sets.push("url = ?"); 256 + } 257 + if site_url.is_some() { 258 + sets.push("site_url = ?"); 259 + } 260 + if category_id.is_some() { 261 + sets.push("group_id = ?"); 262 + } 263 + if sets.is_empty() { 264 + return Ok(false); 265 + } 266 + let sql = format!("UPDATE feeds SET {} WHERE id = ?", sets.join(", ")); 267 + let mut query = sqlx::query(&sql); 268 + if let Some(v) = title { 269 + query = query.bind(v); 270 + } 271 + if let Some(v) = feed_url { 272 + query = query.bind(v); 273 + } 274 + if let Some(v) = site_url { 275 + query = query.bind(v); 276 + } 277 + if let Some(v) = category_id { 278 + query = query.bind(v); 279 + } 280 + query = query.bind(id); 281 + let result = query.execute(pool).await?; 282 + Ok(result.rows_affected() > 0) 283 + } 284 + 285 + pub async fn delete_feed(pool: &SqlitePool, id: i64) -> Result<bool> { 286 + let result = sqlx::query("DELETE FROM feeds WHERE id = ?") 287 + .bind(id) 288 + .execute(pool) 289 + .await?; 290 + Ok(result.rows_affected() > 0) 291 + } 292 + 293 + pub async fn mark_all_feed_entries_read(pool: &SqlitePool, feed_id: i64) -> Result<()> { 294 + sqlx::query( 295 + "UPDATE items SET is_read = 1, changed_at = unixepoch() WHERE is_read = 0 AND feed_id = ?", 296 + ) 297 + .bind(feed_id) 298 + .execute(pool) 299 + .await?; 300 + Ok(()) 301 + } 302 + 303 + pub async fn get_feed_counters(pool: &SqlitePool) -> Result<(HashMap<i64, i64>, HashMap<i64, i64>)> { 304 + let rows: Vec<(i64, i64, i64)> = sqlx::query_as( 305 + "SELECT f.id, \ 306 + COALESCE(SUM(CASE WHEN i.is_read = 1 THEN 1 ELSE 0 END), 0), \ 307 + COALESCE(SUM(CASE WHEN i.is_read = 0 THEN 1 ELSE 0 END), 0) \ 308 + FROM feeds f LEFT JOIN items i ON i.feed_id = f.id GROUP BY f.id", 158 309 ) 159 310 .fetch_all(pool) 160 311 .await?; 161 - Ok(feeds) 312 + 313 + let mut reads = HashMap::new(); 314 + let mut unreads = HashMap::new(); 315 + for (feed_id, read_count, unread_count) in rows { 316 + reads.insert(feed_id, read_count); 317 + unreads.insert(feed_id, unread_count); 318 + } 319 + Ok((reads, unreads)) 162 320 } 163 321 164 - pub async fn get_items( 165 - pool: &SqlitePool, 166 - since_id: Option<i64>, 167 - max_id: Option<i64>, 168 - with_ids: Option<&[i64]>, 169 - ) -> Result<Vec<Item>> { 170 - let items = if let Some(ids) = with_ids { 171 - let placeholders: Vec<&str> = ids.iter().map(|_| "?").collect(); 172 - let sql = format!( 173 - "SELECT id, feed_id, title, author, content, url, is_read, \ 174 - COALESCE(published_at, created_at) as ts \ 175 - FROM items WHERE id IN ({}) ORDER BY id DESC", 176 - placeholders.join(",") 177 - ); 178 - let mut query = sqlx::query_as::<_, Item>(&sql); 179 - for id in ids { 180 - query = query.bind(id); 322 + // --------------------------------------------------------------------------- 323 + // Entries (items table) 324 + // --------------------------------------------------------------------------- 325 + 326 + #[derive(Debug, Clone, sqlx::FromRow)] 327 + pub struct EntryRow { 328 + pub id: i64, 329 + pub feed_id: i64, 330 + pub title: String, 331 + pub author: String, 332 + pub content: String, 333 + pub url: String, 334 + pub is_read: i32, 335 + pub is_starred: i32, 336 + pub published_at: Option<i64>, 337 + pub created_at: i64, 338 + pub changed_at: i64, 339 + // join fields 340 + pub feed_title: String, 341 + pub feed_url: String, 342 + pub feed_site_url: String, 343 + pub category_id: i64, 344 + pub category_title: String, 345 + } 346 + 347 + /// Filter/pagination parameters for entry queries. 348 + #[derive(Debug, Default)] 349 + pub struct EntryFilter { 350 + pub status: Option<String>, // "read", "unread", "removed" 351 + pub feed_id: Option<i64>, 352 + pub category_id: Option<i64>, 353 + pub starred: Option<bool>, 354 + pub search: Option<String>, 355 + pub after: Option<i64>, // unix timestamp (changed_at) 356 + pub after_entry_id: Option<i64>, 357 + pub before: Option<i64>, // unix timestamp (changed_at) 358 + pub before_entry_id: Option<i64>, 359 + pub limit: Option<i64>, 360 + pub offset: Option<i64>, 361 + pub order: Option<String>, // "id", "status", "published_at", "created_at", "category_title", "category_id" 362 + pub direction: Option<String>, // "asc", "desc" 363 + } 364 + 365 + pub async fn get_entry(pool: &SqlitePool, id: i64) -> Result<Option<EntryRow>> { 366 + let row = sqlx::query_as::<_, EntryRow>( 367 + "SELECT i.id, i.feed_id, i.title, i.author, i.content, i.url, i.is_read, i.is_starred, \ 368 + i.published_at, i.created_at, i.changed_at, \ 369 + f.title AS feed_title, f.url AS feed_url, f.site_url AS feed_site_url, \ 370 + g.id AS category_id, g.name AS category_title \ 371 + FROM items i \ 372 + JOIN feeds f ON i.feed_id = f.id \ 373 + JOIN groups g ON f.group_id = g.id \ 374 + WHERE i.id = ?", 375 + ) 376 + .bind(id) 377 + .fetch_optional(pool) 378 + .await?; 379 + Ok(row) 380 + } 381 + 382 + pub async fn get_entries_filtered(pool: &SqlitePool, filter: &EntryFilter) -> Result<(i64, Vec<EntryRow>)> { 383 + let base_from = "FROM items i \ 384 + JOIN feeds f ON i.feed_id = f.id \ 385 + JOIN groups g ON f.group_id = g.id"; 386 + 387 + let mut where_parts: Vec<String> = Vec::new(); 388 + 389 + if let Some(ref status) = filter.status { 390 + match status.as_str() { 391 + "read" => where_parts.push("i.is_read = 1".to_string()), 392 + "unread" => where_parts.push("i.is_read = 0".to_string()), 393 + _ => {} // "removed" not supported, ignore 181 394 } 182 - query.fetch_all(pool).await? 183 - } else if let Some(since) = since_id { 184 - sqlx::query_as::<_, Item>( 185 - "SELECT id, feed_id, title, author, content, url, is_read, \ 186 - COALESCE(published_at, created_at) as ts \ 187 - FROM items WHERE id > ? ORDER BY id ASC LIMIT 50", 188 - ) 189 - .bind(since) 190 - .fetch_all(pool) 191 - .await? 192 - } else if let Some(max) = max_id { 193 - sqlx::query_as::<_, Item>( 194 - "SELECT id, feed_id, title, author, content, url, is_read, \ 195 - COALESCE(published_at, created_at) as ts \ 196 - FROM items WHERE id < ? ORDER BY id DESC LIMIT 50", 395 + } 396 + if let Some(feed_id) = filter.feed_id { 397 + where_parts.push(format!("i.feed_id = {feed_id}")); 398 + } 399 + if let Some(category_id) = filter.category_id { 400 + where_parts.push(format!("f.group_id = {category_id}")); 401 + } 402 + if let Some(starred) = filter.starred { 403 + where_parts.push(format!("i.is_starred = {}", if starred { 1 } else { 0 })); 404 + } 405 + if let Some(after) = filter.after { 406 + where_parts.push(format!("i.changed_at > {after}")); 407 + } 408 + if let Some(id) = filter.after_entry_id { 409 + where_parts.push(format!("i.id > {id}")); 410 + } 411 + if let Some(before) = filter.before { 412 + where_parts.push(format!("i.changed_at < {before}")); 413 + } 414 + if let Some(id) = filter.before_entry_id { 415 + where_parts.push(format!("i.id < {id}")); 416 + } 417 + 418 + let where_clause = if where_parts.is_empty() { 419 + String::new() 420 + } else { 421 + format!(" WHERE {}", where_parts.join(" AND ")) 422 + }; 423 + 424 + // Count query 425 + let count_sql = format!("SELECT COUNT(*) {base_from}{where_clause}"); 426 + let total: (i64,) = if let Some(ref search) = filter.search { 427 + let search_clause = if where_clause.is_empty() { 428 + " WHERE (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 429 + } else { 430 + " AND (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 431 + }; 432 + let sql = format!("SELECT COUNT(*) {base_from}{where_clause}{search_clause}"); 433 + sqlx::query_as(&sql) 434 + .bind(search) 435 + .bind(search) 436 + .fetch_one(pool) 437 + .await? 438 + } else { 439 + sqlx::query_as(&count_sql).fetch_one(pool).await? 440 + }; 441 + 442 + // Order 443 + let order_col = match filter.order.as_deref() { 444 + Some("status") => "i.is_read", 445 + Some("published_at") => "COALESCE(i.published_at, i.created_at)", 446 + Some("created_at") => "i.created_at", 447 + Some("category_title") => "category_title", 448 + Some("category_id") => "g.id", 449 + _ => "i.id", 450 + }; 451 + let direction = match filter.direction.as_deref() { 452 + Some("asc") => "ASC", 453 + _ => "DESC", 454 + }; 455 + 456 + let limit = filter.limit.unwrap_or(100).min(500).max(1); 457 + let offset = filter.offset.unwrap_or(0).max(0); 458 + 459 + let select = "SELECT i.id, i.feed_id, i.title, i.author, i.content, i.url, i.is_read, i.is_starred, \ 460 + i.published_at, i.created_at, i.changed_at, \ 461 + f.title AS feed_title, f.url AS feed_url, f.site_url AS feed_site_url, \ 462 + g.id AS category_id, g.name AS category_title"; 463 + 464 + let entries_sql = if let Some(ref _search) = filter.search { 465 + let search_clause = if where_clause.is_empty() { 466 + " WHERE (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 467 + } else { 468 + " AND (i.title LIKE '%' || ? || '%' OR i.content LIKE '%' || ? || '%')" 469 + }; 470 + format!( 471 + "{select} {base_from}{where_clause}{search_clause} ORDER BY {order_col} {direction} LIMIT {limit} OFFSET {offset}" 197 472 ) 198 - .bind(max) 199 - .fetch_all(pool) 200 - .await? 201 473 } else { 202 - sqlx::query_as::<_, Item>( 203 - "SELECT id, feed_id, title, author, content, url, is_read, \ 204 - COALESCE(published_at, created_at) as ts \ 205 - FROM items ORDER BY id DESC LIMIT 50", 474 + format!( 475 + "{select} {base_from}{where_clause} ORDER BY {order_col} {direction} LIMIT {limit} OFFSET {offset}" 206 476 ) 207 - .fetch_all(pool) 208 - .await? 209 477 }; 210 - Ok(items) 211 - } 212 478 213 - pub async fn get_unread_item_ids(pool: &SqlitePool) -> Result<String> { 214 - let rows: Vec<(i64,)> = 215 - sqlx::query_as("SELECT id FROM items WHERE is_read = 0 ORDER BY id") 479 + let entries: Vec<EntryRow> = if let Some(ref search) = filter.search { 480 + sqlx::query_as::<_, EntryRow>(&entries_sql) 481 + .bind(search) 482 + .bind(search) 216 483 .fetch_all(pool) 217 - .await?; 218 - let ids: Vec<String> = rows.iter().map(|(id,)| id.to_string()).collect(); 219 - Ok(ids.join(",")) 484 + .await? 485 + } else { 486 + sqlx::query_as::<_, EntryRow>(&entries_sql) 487 + .fetch_all(pool) 488 + .await? 489 + }; 490 + 491 + Ok((total.0, entries)) 492 + } 493 + 494 + pub async fn update_entries_status(pool: &SqlitePool, entry_ids: &[i64], status: &str) -> Result<()> { 495 + if entry_ids.is_empty() { 496 + return Ok(()); 497 + } 498 + let is_read = match status { 499 + "read" => 1, 500 + "unread" => 0, 501 + _ => return Ok(()), 502 + }; 503 + let placeholders: Vec<String> = entry_ids.iter().map(|_| "?".to_string()).collect(); 504 + let sql = format!( 505 + "UPDATE items SET is_read = {is_read}, changed_at = unixepoch() WHERE id IN ({})", 506 + placeholders.join(",") 507 + ); 508 + let mut query = sqlx::query(&sql); 509 + for id in entry_ids { 510 + query = query.bind(id); 511 + } 512 + query.execute(pool).await?; 513 + Ok(()) 514 + } 515 + 516 + pub async fn toggle_entry_starred(pool: &SqlitePool, id: i64) -> Result<bool> { 517 + // Returns the new starred state 518 + let result = sqlx::query( 519 + "UPDATE items SET is_starred = 1 - is_starred, changed_at = unixepoch() WHERE id = ?", 520 + ) 521 + .bind(id) 522 + .execute(pool) 523 + .await?; 524 + if result.rows_affected() == 0 { 525 + return Ok(false); 526 + } 527 + // Fetch new state 528 + let row: Option<(i32,)> = sqlx::query_as("SELECT is_starred FROM items WHERE id = ?") 529 + .bind(id) 530 + .fetch_optional(pool) 531 + .await?; 532 + Ok(row.map(|r| r.0 == 1).unwrap_or(false)) 220 533 } 221 534 222 - pub async fn get_favicons(pool: &SqlitePool) -> Result<Vec<Favicon>> { 223 - let favicons = sqlx::query_as::<_, Favicon>("SELECT id, data FROM favicons") 224 - .fetch_all(pool) 535 + // --------------------------------------------------------------------------- 536 + // Icons (favicons table) 537 + // --------------------------------------------------------------------------- 538 + 539 + #[derive(Debug, sqlx::FromRow)] 540 + pub struct IconRow { 541 + pub id: i64, 542 + pub data: String, 543 + } 544 + 545 + pub async fn get_icon_by_id(pool: &SqlitePool, id: i64) -> Result<Option<IconRow>> { 546 + let row = sqlx::query_as::<_, IconRow>("SELECT id, data FROM favicons WHERE id = ?") 547 + .bind(id) 548 + .fetch_optional(pool) 225 549 .await?; 226 - Ok(favicons) 550 + Ok(row) 551 + } 552 + 553 + pub async fn get_icon_by_feed_id(pool: &SqlitePool, feed_id: i64) -> Result<Option<IconRow>> { 554 + let row = sqlx::query_as::<_, IconRow>( 555 + "SELECT fav.id, fav.data FROM favicons fav \ 556 + JOIN feeds f ON f.favicon_id = fav.id WHERE f.id = ?", 557 + ) 558 + .bind(feed_id) 559 + .fetch_optional(pool) 560 + .await?; 561 + Ok(row) 227 562 } 228 563 229 - // -- Fetcher queries -- 564 + // --------------------------------------------------------------------------- 565 + // Fetcher queries (kept from original) 566 + // --------------------------------------------------------------------------- 230 567 231 568 #[derive(sqlx::FromRow)] 232 - pub struct FeedRow { 569 + pub struct FetcherFeed { 233 570 pub id: i64, 234 571 pub url: String, 235 572 pub favicon_id: Option<i64>, 236 573 } 237 574 238 - pub async fn get_all_feeds(pool: &SqlitePool) -> Result<Vec<FeedRow>> { 239 - let feeds = sqlx::query_as::<_, FeedRow>("SELECT id, url, favicon_id FROM feeds") 575 + pub async fn get_all_feeds(pool: &SqlitePool) -> Result<Vec<FetcherFeed>> { 576 + let feeds = sqlx::query_as::<_, FetcherFeed>("SELECT id, url, favicon_id FROM feeds") 240 577 .fetch_all(pool) 241 578 .await?; 242 579 Ok(feeds) 580 + } 581 + 582 + pub async fn get_single_feed_for_fetch(pool: &SqlitePool, feed_id: i64) -> Result<Option<FetcherFeed>> { 583 + let feed = sqlx::query_as::<_, FetcherFeed>("SELECT id, url, favicon_id FROM feeds WHERE id = ?") 584 + .bind(feed_id) 585 + .fetch_optional(pool) 586 + .await?; 587 + Ok(feed) 243 588 } 244 589 245 590 pub async fn upsert_feed_metadata( ··· 303 648 Ok(()) 304 649 } 305 650 306 - // -- Read status operations -- 651 + // --------------------------------------------------------------------------- 652 + // OPML export 653 + // --------------------------------------------------------------------------- 307 654 308 - pub async fn mark_item_read(pool: &SqlitePool, item_id: i64) -> Result<()> { 309 - sqlx::query("UPDATE items SET is_read = 1 WHERE id = ?") 310 - .bind(item_id) 311 - .execute(pool) 312 - .await?; 313 - Ok(()) 655 + #[derive(Debug, sqlx::FromRow)] 656 + pub struct FeedExportRow { 657 + pub feed_title: String, 658 + pub feed_url: String, 659 + pub site_url: String, 660 + pub category_name: String, 314 661 } 315 662 316 - pub async fn mark_item_unread(pool: &SqlitePool, item_id: i64) -> Result<()> { 317 - sqlx::query("UPDATE items SET is_read = 0 WHERE id = ?") 318 - .bind(item_id) 319 - .execute(pool) 320 - .await?; 321 - Ok(()) 663 + pub async fn get_feeds_for_export(pool: &SqlitePool) -> Result<Vec<FeedExportRow>> { 664 + let rows = sqlx::query_as::<_, FeedExportRow>( 665 + "SELECT f.title AS feed_title, f.url AS feed_url, f.site_url, g.name AS category_name \ 666 + FROM feeds f JOIN groups g ON f.group_id = g.id ORDER BY g.name, f.title", 667 + ) 668 + .fetch_all(pool) 669 + .await?; 670 + Ok(rows) 322 671 } 323 672 324 - pub async fn mark_feed_read(pool: &SqlitePool, feed_id: i64, before: Option<i64>) -> Result<()> { 325 - if let Some(ts) = before { 326 - sqlx::query( 327 - "UPDATE items SET is_read = 1 WHERE feed_id = ? AND COALESCE(published_at, created_at) < ?", 328 - ) 329 - .bind(feed_id) 330 - .bind(ts) 331 - .execute(pool) 332 - .await?; 333 - } else { 334 - sqlx::query("UPDATE items SET is_read = 1 WHERE feed_id = ?") 335 - .bind(feed_id) 336 - .execute(pool) 337 - .await?; 338 - } 339 - Ok(()) 340 - } 673 + // --------------------------------------------------------------------------- 674 + // Feed existence check 675 + // --------------------------------------------------------------------------- 341 676 342 - pub async fn mark_group_read(pool: &SqlitePool, group_id: i64, before: Option<i64>) -> Result<()> { 343 - if let Some(ts) = before { 344 - sqlx::query( 345 - "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?) \ 346 - AND COALESCE(published_at, created_at) < ?", 347 - ) 348 - .bind(group_id) 349 - .bind(ts) 350 - .execute(pool) 677 + pub async fn feed_exists(pool: &SqlitePool, id: i64) -> Result<bool> { 678 + let row: Option<(i64,)> = sqlx::query_as("SELECT id FROM feeds WHERE id = ?") 679 + .bind(id) 680 + .fetch_optional(pool) 351 681 .await?; 352 - } else { 353 - sqlx::query( 354 - "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?)", 355 - ) 356 - .bind(group_id) 357 - .execute(pool) 358 - .await?; 359 - } 360 - Ok(()) 682 + Ok(row.is_some()) 361 683 }
+53 -14
src/fetcher.rs
··· 3 3 use sqlx::SqlitePool; 4 4 use std::sync::Arc; 5 5 use std::time::Duration; 6 - use tokio::sync::Semaphore; 6 + use tokio::sync::{Semaphore, broadcast}; 7 7 use tokio::task::JoinSet; 8 8 use tracing::{info, warn}; 9 9 use url::Url; ··· 17 17 .map(|u| u.to_string()) 18 18 } 19 19 20 - pub async fn run(pool: SqlitePool, interval_minutes: u64) { 20 + /// Run the periodic fetch loop, also listening for manual refresh triggers. 21 + pub async fn run( 22 + pool: SqlitePool, 23 + interval_minutes: u64, 24 + mut trigger_rx: broadcast::Receiver<Option<i64>>, 25 + ) { 21 26 let mut interval = tokio::time::interval(Duration::from_secs(interval_minutes * 60)); 22 27 23 28 loop { 24 - interval.tick().await; 25 - info!("starting feed fetch cycle"); 26 - if let Err(e) = fetch_all(&pool).await { 27 - warn!("feed fetch cycle failed: {e:#}"); 29 + tokio::select! { 30 + _ = interval.tick() => { 31 + info!("starting periodic feed fetch cycle"); 32 + if let Err(e) = fetch_all(&pool).await { 33 + warn!("periodic feed fetch cycle failed: {e:#}"); 34 + } 35 + } 36 + Ok(trigger) = trigger_rx.recv() => { 37 + match trigger { 38 + Some(feed_id) => { 39 + info!(feed_id, "manual refresh triggered for single feed"); 40 + if let Err(e) = fetch_single(&pool, feed_id).await { 41 + warn!(feed_id, "manual feed refresh failed: {e:#}"); 42 + } 43 + } 44 + None => { 45 + info!("manual refresh triggered for all feeds"); 46 + if let Err(e) = fetch_all(&pool).await { 47 + warn!("manual feed fetch cycle failed: {e:#}"); 48 + } 49 + } 50 + } 51 + } 28 52 } 29 53 } 54 + } 55 + 56 + async fn fetch_single(pool: &SqlitePool, feed_id: i64) -> Result<()> { 57 + let feed = db::get_single_feed_for_fetch(pool, feed_id) 58 + .await? 59 + .ok_or_else(|| anyhow::anyhow!("feed {feed_id} not found"))?; 60 + let client = reqwest::Client::builder() 61 + .timeout(Duration::from_secs(30)) 62 + .build()?; 63 + fetch_feed(pool, &client, &feed).await 30 64 } 31 65 32 66 async fn fetch_all(pool: &SqlitePool) -> Result<()> { ··· 70 104 Ok(()) 71 105 } 72 106 73 - async fn fetch_feed(pool: &SqlitePool, client: &reqwest::Client, feed: &db::FeedRow) -> Result<()> { 107 + async fn fetch_feed( 108 + pool: &SqlitePool, 109 + client: &reqwest::Client, 110 + feed: &db::FetcherFeed, 111 + ) -> Result<()> { 74 112 info!(feed_id = feed.id, url = %feed.url, "fetching"); 75 113 76 114 let response = client.get(&feed.url).send().await?.bytes().await?; ··· 103 141 }); 104 142 105 143 if let Some(url) = icon_url 106 - && let Some(resolved) = resolve_url(&feed.url, &url) { 107 - match fetch_favicon(client, &resolved).await { 108 - Ok(data) => { 109 - let favicon_id = db::insert_favicon(pool, &data).await?; 110 - db::set_feed_favicon(pool, feed.id, favicon_id).await?; 111 - } 112 - Err(e) => warn!(feed_id = feed.id, "favicon fetch failed: {e:#}"), 144 + && let Some(resolved) = resolve_url(&feed.url, &url) 145 + { 146 + match fetch_favicon(client, &resolved).await { 147 + Ok(data) => { 148 + let favicon_id = db::insert_favicon(pool, &data).await?; 149 + db::set_feed_favicon(pool, feed.id, favicon_id).await?; 113 150 } 151 + Err(e) => warn!(feed_id = feed.id, "favicon fetch failed: {e:#}"), 114 152 } 153 + } 115 154 } 116 155 117 156 for entry in parsed.entries {
+5
src/lib.rs
··· 1 + pub mod api; 2 + pub mod config; 3 + pub mod db; 4 + pub mod fetcher; 5 + pub mod server;
+63 -135
src/main.rs
··· 6 6 7 7 use anyhow::Result; 8 8 use clap::{Parser, Subcommand}; 9 - use md5::{Digest, Md5}; 10 9 use std::path::PathBuf; 11 - use toml_edit::{DocumentMut, Item, Table}; 12 10 13 11 #[derive(Parser)] 14 - #[command(name = "slurp", about = "Headless RSS aggregator")] 12 + #[command(name = "slurp", about = "Headless RSS aggregator with Miniflux API")] 15 13 struct Cli { 16 14 #[command(subcommand)] 17 15 command: Command, ··· 24 22 #[arg(short, long, default_value = "slurp.toml")] 25 23 config: PathBuf, 26 24 }, 27 - /// Generate API key hash from email and password 28 - Auth { email: String, password: String }, 29 - /// Add a feed to the config file 30 - Add { 31 - url: String, 32 - group: String, 33 - #[arg(short, long, default_value = "slurp.toml")] 34 - config: PathBuf, 35 - }, 36 - /// Import feeds from an OPML file 25 + /// Generate a random API token 26 + Token, 27 + /// Import feeds from an OPML file into the database 37 28 Import { 38 29 opml_path: PathBuf, 39 30 #[arg(short, long, default_value = "slurp.toml")] ··· 51 42 Command::Serve { config: path } => { 52 43 let config = config::Config::load(&path)?; 53 44 let pool = db::init_pool(&config.database.path).await?; 54 - db::sync_config(&pool, &config).await?; 45 + 46 + // One-time legacy bootstrap (only if DB is empty and config has groups/feeds) 47 + db::bootstrap_from_legacy_config_if_empty(&pool, &config).await?; 48 + 49 + // Set up refresh trigger channel 50 + let (refresh_tx, refresh_rx) = tokio::sync::broadcast::channel::<Option<i64>>(16); 55 51 56 52 let state = server::AppState { 57 53 pool: pool.clone(), 58 54 api_key: config.server.api_key.clone(), 55 + refresh_trigger: Some(refresh_tx), 59 56 }; 60 57 61 58 let app = server::router(state); 62 59 let listener = tokio::net::TcpListener::bind(&config.server.bind).await?; 63 60 tracing::info!("listening on {}", config.server.bind); 64 61 65 - tokio::spawn(fetcher::run(pool, config.fetcher.interval_minutes)); 62 + tokio::spawn(fetcher::run(pool, config.fetcher.interval_minutes, refresh_rx)); 66 63 67 64 axum::serve(listener, app).await?; 68 65 } 69 - Command::Auth { email, password } => { 70 - let input = format!("{email}:{password}"); 71 - let hash = format!("{:x}", Md5::digest(input.as_bytes())); 72 - println!("{hash}"); 73 - } 74 - Command::Add { url, group, config } => { 75 - let content = if config.exists() { 76 - std::fs::read_to_string(&config)? 77 - } else { 78 - // create minimal config skeleton 79 - r#"[server] 80 - bind = "127.0.0.1:8080" 81 - api_key = "CHANGE_ME" 82 - 83 - [database] 84 - path = "slurp.db" 85 - 86 - [fetcher] 87 - interval_minutes = 30 88 - "# 89 - .to_string() 90 - }; 91 - let mut doc: DocumentMut = content.parse()?; 92 - 93 - // ensure [[groups]] array exists and contains the group 94 - if !doc.contains_key("groups") { 95 - doc["groups"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new()); 66 + Command::Token => { 67 + // Generate a random 32-byte hex token 68 + use std::fmt::Write; 69 + let mut buf = [0u8; 32]; 70 + getrandom::fill(&mut buf).expect("failed to generate random bytes"); 71 + let mut hex = String::with_capacity(64); 72 + for byte in &buf { 73 + write!(hex, "{byte:02x}").unwrap(); 96 74 } 97 - let groups = doc["groups"].as_array_of_tables_mut().unwrap(); 98 - let group_exists = groups 99 - .iter() 100 - .any(|t| t.get("name").and_then(|v| v.as_str()) == Some(&group)); 101 - if !group_exists { 102 - let mut new_group = Table::new(); 103 - new_group["name"] = toml_edit::value(&group); 104 - groups.push(new_group); 105 - } 106 - 107 - // ensure [[feeds]] array exists and add the feed 108 - if !doc.contains_key("feeds") { 109 - doc["feeds"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new()); 110 - } 111 - let feeds = doc["feeds"].as_array_of_tables_mut().unwrap(); 112 - let mut new_feed = Table::new(); 113 - new_feed["url"] = toml_edit::value(&url); 114 - new_feed["group"] = toml_edit::value(&group); 115 - feeds.push(new_feed); 116 - 117 - std::fs::write(&config, doc.to_string())?; 118 - println!("Added feed {} to group {}", url, group); 75 + println!("{hex}"); 119 76 } 120 77 Command::Import { opml_path, config } => { 78 + let cfg = config::Config::load(&config)?; 79 + let pool = db::init_pool(&cfg.database.path).await?; 80 + 121 81 let opml_content = std::fs::read_to_string(&opml_path)?; 122 82 let opml_doc = opml::OPML::from_str(&opml_content)?; 123 83 124 - let content = if config.exists() { 125 - std::fs::read_to_string(&config)? 126 - } else { 127 - r#"[server] 128 - bind = "127.0.0.1:8080" 129 - api_key = "CHANGE_ME" 130 - 131 - [database] 132 - path = "slurp.db" 133 - 134 - [fetcher] 135 - interval_minutes = 30 136 - "# 137 - .to_string() 138 - }; 139 - let mut doc: DocumentMut = content.parse()?; 84 + let mut feed_count = 0u64; 140 85 141 - if !doc.contains_key("groups") { 142 - doc["groups"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new()); 143 - } 144 - if !doc.contains_key("feeds") { 145 - doc["feeds"] = Item::ArrayOfTables(toml_edit::ArrayOfTables::new()); 146 - } 147 - 148 - let mut feed_count = 0; 149 - let mut groups_added = std::collections::HashSet::new(); 86 + fn process_outlines<'a>( 87 + outlines: &'a [opml::Outline], 88 + group_name: &'a str, 89 + pool: &'a sqlx::SqlitePool, 90 + feed_count: &'a mut u64, 91 + ) -> std::pin::Pin<Box<dyn std::future::Future<Output = Result<()>> + Send + 'a>> 92 + { 93 + Box::pin(async move { 94 + // Ensure category exists 95 + sqlx::query("INSERT OR IGNORE INTO groups (name) VALUES (?)") 96 + .bind(group_name) 97 + .execute(pool) 98 + .await?; 150 99 151 - fn process_outlines( 152 - outlines: &[opml::Outline], 153 - group_name: &str, 154 - doc: &mut DocumentMut, 155 - feed_count: &mut usize, 156 - groups_added: &mut std::collections::HashSet<String>, 157 - ) { 158 - for outline in outlines { 159 - if let Some(ref url) = outline.xml_url { 160 - // this is a feed 161 - let groups = doc["groups"].as_array_of_tables_mut().unwrap(); 162 - let group_exists = groups 163 - .iter() 164 - .any(|t| t.get("name").and_then(|v| v.as_str()) == Some(group_name)); 165 - if !group_exists { 166 - let mut new_group = Table::new(); 167 - new_group["name"] = toml_edit::value(group_name); 168 - groups.push(new_group); 169 - groups_added.insert(group_name.to_string()); 100 + for outline in outlines { 101 + if let Some(ref url) = outline.xml_url { 102 + let group_id: (i64,) = 103 + sqlx::query_as("SELECT id FROM groups WHERE name = ?") 104 + .bind(group_name) 105 + .fetch_one(pool) 106 + .await?; 107 + sqlx::query( 108 + "INSERT OR IGNORE INTO feeds (url, group_id) VALUES (?, ?)", 109 + ) 110 + .bind(url) 111 + .bind(group_id.0) 112 + .execute(pool) 113 + .await?; 114 + *feed_count += 1; 115 + } else if !outline.outlines.is_empty() { 116 + let child_group = outline.text.as_str(); 117 + process_outlines(&outline.outlines, child_group, pool, feed_count) 118 + .await?; 170 119 } 171 - 172 - let feeds = doc["feeds"].as_array_of_tables_mut().unwrap(); 173 - let mut new_feed = Table::new(); 174 - new_feed["url"] = toml_edit::value(url); 175 - new_feed["group"] = toml_edit::value(group_name); 176 - feeds.push(new_feed); 177 - *feed_count += 1; 178 - } else if !outline.outlines.is_empty() { 179 - // this is a group containing feeds 180 - let child_group = outline.text.as_str(); 181 - process_outlines( 182 - &outline.outlines, 183 - child_group, 184 - doc, 185 - feed_count, 186 - groups_added, 187 - ); 188 120 } 189 - } 121 + Ok(()) 122 + }) 190 123 } 191 124 192 125 process_outlines( 193 126 &opml_doc.body.outlines, 194 127 "Uncategorized", 195 - &mut doc, 128 + &pool, 196 129 &mut feed_count, 197 - &mut groups_added, 198 - ); 130 + ) 131 + .await?; 199 132 200 - std::fs::write(&config, doc.to_string())?; 201 - println!( 202 - "Imported {} feeds into {} groups", 203 - feed_count, 204 - groups_added.len() 205 - ); 133 + println!("Imported {feed_count} feeds into the database"); 206 134 } 207 135 } 208 136
+16 -9
src/server.rs
··· 1 + use axum::middleware; 1 2 use axum::routing::get; 2 3 use axum::Router; 3 4 use sqlx::SqlitePool; 5 + use tokio::sync::broadcast; 4 6 5 7 use crate::api; 6 8 ··· 8 10 pub struct AppState { 9 11 pub pool: SqlitePool, 10 12 pub api_key: String, 13 + /// Send `Some(feed_id)` for single-feed refresh, `None` for all feeds. 14 + pub refresh_trigger: Option<broadcast::Sender<Option<i64>>>, 11 15 } 12 16 13 17 pub fn router(state: AppState) -> Router { 18 + let v1 = api::miniflux::v1_router().layer(middleware::from_fn_with_state( 19 + state.clone(), 20 + api::miniflux::auth::auth_middleware, 21 + )); 22 + 14 23 Router::new() 15 - .route("/", get(api::fever::handler).post(api::fever::handler)) 16 - .route( 17 - "/fever", 18 - get(api::fever::handler).post(api::fever::handler), 19 - ) 20 - .route( 21 - "/fever/", 22 - get(api::fever::handler).post(api::fever::handler), 23 - ) 24 + .nest("/v1", v1) 25 + // Health/readiness endpoints (unauthenticated, root-level) 26 + .route("/healthcheck", get(api::miniflux::handlers::healthcheck)) 27 + .route("/liveness", get(api::miniflux::handlers::healthcheck)) 28 + .route("/healthz", get(api::miniflux::handlers::healthcheck)) 29 + .route("/readiness", get(api::miniflux::handlers::healthcheck)) 30 + .route("/readyz", get(api::miniflux::handlers::healthcheck)) 24 31 .with_state(state) 25 32 }
-199
tests/fever_integration.rs
··· 1 - //! Integration tests for Fever API mark operations 2 - 3 - use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 4 - use sqlx::SqlitePool; 5 - use std::str::FromStr; 6 - 7 - async fn setup_test_db() -> SqlitePool { 8 - let options = SqliteConnectOptions::from_str("sqlite::memory:") 9 - .unwrap() 10 - .create_if_missing(true); 11 - 12 - let pool = SqlitePoolOptions::new() 13 - .max_connections(1) 14 - .connect_with(options) 15 - .await 16 - .unwrap(); 17 - 18 - // Create schema 19 - sqlx::query( 20 - r#" 21 - CREATE TABLE groups ( 22 - id INTEGER PRIMARY KEY AUTOINCREMENT, 23 - name TEXT NOT NULL UNIQUE 24 - ); 25 - CREATE TABLE feeds ( 26 - id INTEGER PRIMARY KEY AUTOINCREMENT, 27 - url TEXT NOT NULL UNIQUE, 28 - title TEXT NOT NULL DEFAULT '', 29 - group_id INTEGER NOT NULL REFERENCES groups(id) 30 - ); 31 - CREATE TABLE items ( 32 - id INTEGER PRIMARY KEY AUTOINCREMENT, 33 - feed_id INTEGER NOT NULL REFERENCES feeds(id), 34 - guid TEXT NOT NULL UNIQUE, 35 - title TEXT NOT NULL DEFAULT '', 36 - author TEXT NOT NULL DEFAULT '', 37 - url TEXT NOT NULL DEFAULT '', 38 - content TEXT NOT NULL DEFAULT '', 39 - published_at INTEGER, 40 - created_at INTEGER NOT NULL DEFAULT (unixepoch()), 41 - is_read INTEGER NOT NULL DEFAULT 0 42 - ); 43 - "#, 44 - ) 45 - .execute(&pool) 46 - .await 47 - .unwrap(); 48 - 49 - pool 50 - } 51 - 52 - async fn insert_test_data(pool: &SqlitePool) { 53 - // Create group 54 - sqlx::query("INSERT INTO groups (id, name) VALUES (1, 'Tech')") 55 - .execute(pool) 56 - .await 57 - .unwrap(); 58 - 59 - // Create feed 60 - sqlx::query("INSERT INTO feeds (id, url, title, group_id) VALUES (1, 'http://example.com/feed', 'Example', 1)") 61 - .execute(pool) 62 - .await 63 - .unwrap(); 64 - 65 - // Create items with different timestamps 66 - // Item 1: published at timestamp 1000 (old) 67 - // Item 2: published at timestamp 2000 (middle) 68 - // Item 3: published at timestamp 3000 (new) 69 - sqlx::query("INSERT INTO items (id, feed_id, guid, title, published_at, is_read) VALUES (1, 1, 'guid1', 'Old Article', 1000, 0)") 70 - .execute(pool) 71 - .await 72 - .unwrap(); 73 - sqlx::query("INSERT INTO items (id, feed_id, guid, title, published_at, is_read) VALUES (2, 1, 'guid2', 'Middle Article', 2000, 0)") 74 - .execute(pool) 75 - .await 76 - .unwrap(); 77 - sqlx::query("INSERT INTO items (id, feed_id, guid, title, published_at, is_read) VALUES (3, 1, 'guid3', 'New Article', 3000, 0)") 78 - .execute(pool) 79 - .await 80 - .unwrap(); 81 - } 82 - 83 - async fn get_unread_count(pool: &SqlitePool) -> i64 { 84 - let (count,): (i64,) = sqlx::query_as("SELECT COUNT(*) FROM items WHERE is_read = 0") 85 - .fetch_one(pool) 86 - .await 87 - .unwrap(); 88 - count 89 - } 90 - 91 - async fn get_unread_ids(pool: &SqlitePool) -> Vec<i64> { 92 - let rows: Vec<(i64,)> = sqlx::query_as("SELECT id FROM items WHERE is_read = 0 ORDER BY id") 93 - .fetch_all(pool) 94 - .await 95 - .unwrap(); 96 - rows.into_iter().map(|(id,)| id).collect() 97 - } 98 - 99 - // Inline the fixed mark_feed_read function for testing 100 - async fn mark_feed_read(pool: &SqlitePool, feed_id: i64, before: Option<i64>) { 101 - if let Some(ts) = before { 102 - sqlx::query( 103 - "UPDATE items SET is_read = 1 WHERE feed_id = ? AND COALESCE(published_at, created_at) < ?", 104 - ) 105 - .bind(feed_id) 106 - .bind(ts) 107 - .execute(pool) 108 - .await 109 - .unwrap(); 110 - } else { 111 - sqlx::query("UPDATE items SET is_read = 1 WHERE feed_id = ?") 112 - .bind(feed_id) 113 - .execute(pool) 114 - .await 115 - .unwrap(); 116 - } 117 - } 118 - 119 - async fn mark_group_read(pool: &SqlitePool, group_id: i64, before: Option<i64>) { 120 - if let Some(ts) = before { 121 - sqlx::query( 122 - "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?) \ 123 - AND COALESCE(published_at, created_at) < ?", 124 - ) 125 - .bind(group_id) 126 - .bind(ts) 127 - .execute(pool) 128 - .await 129 - .unwrap(); 130 - } else { 131 - sqlx::query( 132 - "UPDATE items SET is_read = 1 WHERE feed_id IN (SELECT id FROM feeds WHERE group_id = ?)", 133 - ) 134 - .bind(group_id) 135 - .execute(pool) 136 - .await 137 - .unwrap(); 138 - } 139 - } 140 - 141 - #[tokio::test] 142 - async fn test_mark_feed_read_with_before_marks_only_older_items() { 143 - let pool = setup_test_db().await; 144 - insert_test_data(&pool).await; 145 - 146 - // All 3 items should be unread 147 - assert_eq!(get_unread_count(&pool).await, 3); 148 - 149 - // Mark feed as read with before=2500 150 - // Should only mark items with published_at < 2500 (items 1 and 2) 151 - mark_feed_read(&pool, 1, Some(2500)).await; 152 - 153 - // Only item 3 (published_at=3000) should remain unread 154 - assert_eq!(get_unread_count(&pool).await, 1); 155 - assert_eq!(get_unread_ids(&pool).await, vec![3]); 156 - } 157 - 158 - #[tokio::test] 159 - async fn test_mark_feed_read_without_before_marks_all_items() { 160 - let pool = setup_test_db().await; 161 - insert_test_data(&pool).await; 162 - 163 - assert_eq!(get_unread_count(&pool).await, 3); 164 - 165 - // Mark feed as read without before parameter 166 - mark_feed_read(&pool, 1, None).await; 167 - 168 - // All items should be marked as read 169 - assert_eq!(get_unread_count(&pool).await, 0); 170 - } 171 - 172 - #[tokio::test] 173 - async fn test_mark_group_read_with_before() { 174 - let pool = setup_test_db().await; 175 - insert_test_data(&pool).await; 176 - 177 - assert_eq!(get_unread_count(&pool).await, 3); 178 - 179 - // Mark group as read with before=2500 180 - mark_group_read(&pool, 1, Some(2500)).await; 181 - 182 - // Only item 3 should remain unread 183 - assert_eq!(get_unread_count(&pool).await, 1); 184 - assert_eq!(get_unread_ids(&pool).await, vec![3]); 185 - } 186 - 187 - #[tokio::test] 188 - async fn test_mark_feed_read_before_boundary() { 189 - let pool = setup_test_db().await; 190 - insert_test_data(&pool).await; 191 - 192 - // Mark with before=2000 (exactly at item 2's timestamp) 193 - // Should only mark item 1 (published_at < 2000) 194 - mark_feed_read(&pool, 1, Some(2000)).await; 195 - 196 - // Items 2 and 3 should remain unread 197 - assert_eq!(get_unread_count(&pool).await, 2); 198 - assert_eq!(get_unread_ids(&pool).await, vec![2, 3]); 199 - }
-38
tests/fever_test.rs
··· 1 - //! Unit tests for Fever API parameter parsing 2 - 3 - use std::collections::HashMap; 4 - 5 - // Test that mark operations properly parse the `before` parameter 6 - #[test] 7 - fn test_mark_feed_before_param_parsing() { 8 - // Simulate body params from a Fever client 9 - let body = "api_key=test123&mark=feed&as=read&id=1&before=1738430000"; 10 - let body_params: HashMap<String, String> = serde_urlencoded::from_str(body).unwrap(); 11 - 12 - assert_eq!(body_params.get("mark"), Some(&"feed".to_string())); 13 - assert_eq!(body_params.get("as"), Some(&"read".to_string())); 14 - assert_eq!(body_params.get("id"), Some(&"1".to_string())); 15 - assert_eq!(body_params.get("before"), Some(&"1738430000".to_string())); 16 - } 17 - 18 - #[test] 19 - fn test_mark_group_before_param_parsing() { 20 - let body = "api_key=test123&mark=group&as=read&id=2&before=1738430000"; 21 - let body_params: HashMap<String, String> = serde_urlencoded::from_str(body).unwrap(); 22 - 23 - assert_eq!(body_params.get("mark"), Some(&"group".to_string())); 24 - assert_eq!(body_params.get("as"), Some(&"read".to_string())); 25 - assert_eq!(body_params.get("id"), Some(&"2".to_string())); 26 - assert_eq!(body_params.get("before"), Some(&"1738430000".to_string())); 27 - } 28 - 29 - #[test] 30 - fn test_mark_item_parsing() { 31 - let body = "api_key=test123&mark=item&as=read&id=42"; 32 - let body_params: HashMap<String, String> = serde_urlencoded::from_str(body).unwrap(); 33 - 34 - assert_eq!(body_params.get("mark"), Some(&"item".to_string())); 35 - assert_eq!(body_params.get("as"), Some(&"read".to_string())); 36 - assert_eq!(body_params.get("id"), Some(&"42".to_string())); 37 - assert!(body_params.get("before").is_none()); 38 - }
+601
tests/miniflux_test.rs
··· 1 + use axum::body::Body; 2 + use axum::http::{Request, StatusCode}; 3 + use base64::Engine; 4 + use http_body_util::BodyExt; 5 + use serde_json::{Value, json}; 6 + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; 7 + use sqlx::SqlitePool; 8 + use std::str::FromStr; 9 + use tower::ServiceExt; 10 + 11 + async fn setup_test_db() -> SqlitePool { 12 + let options = SqliteConnectOptions::from_str("sqlite::memory:") 13 + .unwrap() 14 + .create_if_missing(true); 15 + 16 + let pool = SqlitePoolOptions::new() 17 + .max_connections(1) 18 + .connect_with(options) 19 + .await 20 + .unwrap(); 21 + 22 + // Run all migrations 23 + sqlx::migrate!("./migrations") 24 + .run(&pool) 25 + .await 26 + .unwrap(); 27 + 28 + pool 29 + } 30 + 31 + async fn insert_test_data(pool: &SqlitePool) { 32 + sqlx::query("INSERT INTO groups (id, name) VALUES (1, 'Tech')") 33 + .execute(pool) 34 + .await 35 + .unwrap(); 36 + sqlx::query("INSERT INTO groups (id, name) VALUES (2, 'News')") 37 + .execute(pool) 38 + .await 39 + .unwrap(); 40 + sqlx::query("INSERT INTO feeds (id, url, title, site_url, group_id) VALUES (1, 'http://example.com/feed', 'Example Feed', 'http://example.com', 1)") 41 + .execute(pool) 42 + .await 43 + .unwrap(); 44 + sqlx::query("INSERT INTO feeds (id, url, title, site_url, group_id) VALUES (2, 'http://news.com/rss', 'News Feed', 'http://news.com', 2)") 45 + .execute(pool) 46 + .await 47 + .unwrap(); 48 + 49 + // Insert items 50 + sqlx::query("INSERT INTO items (id, feed_id, guid, title, author, url, content, published_at, is_read, is_starred) VALUES (1, 1, 'guid1', 'Article One', 'Alice', 'http://example.com/1', 'Content one', 1000, 0, 0)") 51 + .execute(pool).await.unwrap(); 52 + sqlx::query("INSERT INTO items (id, feed_id, guid, title, author, url, content, published_at, is_read, is_starred) VALUES (2, 1, 'guid2', 'Article Two', 'Bob', 'http://example.com/2', 'Content two', 2000, 1, 0)") 53 + .execute(pool).await.unwrap(); 54 + sqlx::query("INSERT INTO items (id, feed_id, guid, title, author, url, content, published_at, is_read, is_starred) VALUES (3, 2, 'guid3', 'News One', 'Charlie', 'http://news.com/1', 'News content', 3000, 0, 1)") 55 + .execute(pool).await.unwrap(); 56 + } 57 + 58 + fn make_app(pool: SqlitePool) -> axum::Router { 59 + let state = slurp::server::AppState { 60 + pool, 61 + api_key: "test-token".to_string(), 62 + refresh_trigger: None, 63 + }; 64 + slurp::server::router(state) 65 + } 66 + 67 + fn auth_header() -> (&'static str, &'static str) { 68 + ("X-Auth-Token", "test-token") 69 + } 70 + 71 + async fn get_json(app: &axum::Router, path: &str) -> (StatusCode, Value) { 72 + let (hk, hv) = auth_header(); 73 + let response = app 74 + .clone() 75 + .oneshot( 76 + Request::builder() 77 + .uri(path) 78 + .header(hk, hv) 79 + .body(Body::empty()) 80 + .unwrap(), 81 + ) 82 + .await 83 + .unwrap(); 84 + let status = response.status(); 85 + let body = response.into_body().collect().await.unwrap().to_bytes(); 86 + let json: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); 87 + (status, json) 88 + } 89 + 90 + async fn request_with_body( 91 + app: &axum::Router, 92 + method: &str, 93 + path: &str, 94 + body: Value, 95 + ) -> (StatusCode, Value) { 96 + let (hk, hv) = auth_header(); 97 + let response = app 98 + .clone() 99 + .oneshot( 100 + Request::builder() 101 + .method(method) 102 + .uri(path) 103 + .header(hk, hv) 104 + .header("Content-Type", "application/json") 105 + .body(Body::from(serde_json::to_vec(&body).unwrap())) 106 + .unwrap(), 107 + ) 108 + .await 109 + .unwrap(); 110 + let status = response.status(); 111 + let body = response.into_body().collect().await.unwrap().to_bytes(); 112 + let json: Value = serde_json::from_slice(&body).unwrap_or(Value::Null); 113 + (status, json) 114 + } 115 + 116 + async fn put_no_body(app: &axum::Router, path: &str) -> StatusCode { 117 + let (hk, hv) = auth_header(); 118 + let response = app 119 + .clone() 120 + .oneshot( 121 + Request::builder() 122 + .method("PUT") 123 + .uri(path) 124 + .header(hk, hv) 125 + .body(Body::empty()) 126 + .unwrap(), 127 + ) 128 + .await 129 + .unwrap(); 130 + response.status() 131 + } 132 + 133 + // --------------------------------------------------------------------------- 134 + // Auth tests 135 + // --------------------------------------------------------------------------- 136 + 137 + #[tokio::test] 138 + async fn test_auth_x_auth_token() { 139 + let pool = setup_test_db().await; 140 + let app = make_app(pool); 141 + let (status, json) = get_json(&app, "/v1/me").await; 142 + assert_eq!(status, StatusCode::OK); 143 + assert_eq!(json["username"], "admin"); 144 + } 145 + 146 + #[tokio::test] 147 + async fn test_auth_basic() { 148 + let pool = setup_test_db().await; 149 + let app = make_app(pool); 150 + let encoded = base64::engine::general_purpose::STANDARD.encode("user:test-token"); 151 + let response = app 152 + .oneshot( 153 + Request::builder() 154 + .uri("/v1/me") 155 + .header("Authorization", format!("Basic {encoded}")) 156 + .body(Body::empty()) 157 + .unwrap(), 158 + ) 159 + .await 160 + .unwrap(); 161 + assert_eq!(response.status(), StatusCode::OK); 162 + } 163 + 164 + #[tokio::test] 165 + async fn test_auth_rejected() { 166 + let pool = setup_test_db().await; 167 + let app = make_app(pool); 168 + let response = app 169 + .oneshot( 170 + Request::builder() 171 + .uri("/v1/me") 172 + .header("X-Auth-Token", "wrong-token") 173 + .body(Body::empty()) 174 + .unwrap(), 175 + ) 176 + .await 177 + .unwrap(); 178 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 179 + } 180 + 181 + #[tokio::test] 182 + async fn test_auth_missing() { 183 + let pool = setup_test_db().await; 184 + let app = make_app(pool); 185 + let response = app 186 + .oneshot( 187 + Request::builder() 188 + .uri("/v1/me") 189 + .body(Body::empty()) 190 + .unwrap(), 191 + ) 192 + .await 193 + .unwrap(); 194 + assert_eq!(response.status(), StatusCode::UNAUTHORIZED); 195 + } 196 + 197 + // --------------------------------------------------------------------------- 198 + // Health endpoints (unauthenticated) 199 + // --------------------------------------------------------------------------- 200 + 201 + #[tokio::test] 202 + async fn test_healthcheck() { 203 + let pool = setup_test_db().await; 204 + let app = make_app(pool); 205 + let response = app 206 + .oneshot( 207 + Request::builder() 208 + .uri("/healthcheck") 209 + .body(Body::empty()) 210 + .unwrap(), 211 + ) 212 + .await 213 + .unwrap(); 214 + assert_eq!(response.status(), StatusCode::OK); 215 + } 216 + 217 + #[tokio::test] 218 + async fn test_healthz() { 219 + let pool = setup_test_db().await; 220 + let app = make_app(pool); 221 + let response = app 222 + .oneshot( 223 + Request::builder() 224 + .uri("/healthz") 225 + .body(Body::empty()) 226 + .unwrap(), 227 + ) 228 + .await 229 + .unwrap(); 230 + assert_eq!(response.status(), StatusCode::OK); 231 + } 232 + 233 + // --------------------------------------------------------------------------- 234 + // Categories 235 + // --------------------------------------------------------------------------- 236 + 237 + #[tokio::test] 238 + async fn test_get_categories() { 239 + let pool = setup_test_db().await; 240 + insert_test_data(&pool).await; 241 + let app = make_app(pool); 242 + let (status, json) = get_json(&app, "/v1/categories").await; 243 + assert_eq!(status, StatusCode::OK); 244 + let cats = json.as_array().unwrap(); 245 + assert_eq!(cats.len(), 2); 246 + } 247 + 248 + #[tokio::test] 249 + async fn test_get_categories_with_counts() { 250 + let pool = setup_test_db().await; 251 + insert_test_data(&pool).await; 252 + let app = make_app(pool); 253 + let (status, json) = get_json(&app, "/v1/categories?counts=true").await; 254 + assert_eq!(status, StatusCode::OK); 255 + let cats = json.as_array().unwrap(); 256 + // Tech has 2 feeds items (1 unread), News has 1 (1 unread) 257 + let tech = cats.iter().find(|c| c["title"] == "Tech").unwrap(); 258 + assert_eq!(tech["feed_count"], 1); 259 + assert_eq!(tech["total_unread"], 1); 260 + } 261 + 262 + #[tokio::test] 263 + async fn test_create_category() { 264 + let pool = setup_test_db().await; 265 + let app = make_app(pool); 266 + let (status, json) = request_with_body( 267 + &app, 268 + "POST", 269 + "/v1/categories", 270 + json!({"title": "Science"}), 271 + ) 272 + .await; 273 + assert_eq!(status, StatusCode::CREATED); 274 + assert_eq!(json["title"], "Science"); 275 + assert!(json["id"].as_i64().unwrap() > 0); 276 + } 277 + 278 + #[tokio::test] 279 + async fn test_update_category() { 280 + let pool = setup_test_db().await; 281 + insert_test_data(&pool).await; 282 + let app = make_app(pool); 283 + let (status, json) = request_with_body( 284 + &app, 285 + "PUT", 286 + "/v1/categories/1", 287 + json!({"title": "Technology"}), 288 + ) 289 + .await; 290 + assert_eq!(status, StatusCode::OK); 291 + assert_eq!(json["title"], "Technology"); 292 + } 293 + 294 + #[tokio::test] 295 + async fn test_delete_category() { 296 + let pool = setup_test_db().await; 297 + insert_test_data(&pool).await; 298 + let app = make_app(pool); 299 + let status = { 300 + let (hk, hv) = auth_header(); 301 + let response = app 302 + .clone() 303 + .oneshot( 304 + Request::builder() 305 + .method("DELETE") 306 + .uri("/v1/categories/2") 307 + .header(hk, hv) 308 + .body(Body::empty()) 309 + .unwrap(), 310 + ) 311 + .await 312 + .unwrap(); 313 + response.status() 314 + }; 315 + assert_eq!(status, StatusCode::NO_CONTENT); 316 + 317 + // Verify it's gone 318 + let (status, json) = get_json(&app, "/v1/categories").await; 319 + assert_eq!(status, StatusCode::OK); 320 + assert_eq!(json.as_array().unwrap().len(), 1); 321 + } 322 + 323 + // --------------------------------------------------------------------------- 324 + // Feeds 325 + // --------------------------------------------------------------------------- 326 + 327 + #[tokio::test] 328 + async fn test_get_feeds() { 329 + let pool = setup_test_db().await; 330 + insert_test_data(&pool).await; 331 + let app = make_app(pool); 332 + let (status, json) = get_json(&app, "/v1/feeds").await; 333 + assert_eq!(status, StatusCode::OK); 334 + let feeds = json.as_array().unwrap(); 335 + assert_eq!(feeds.len(), 2); 336 + // Verify category is nested 337 + assert!(feeds[0]["category"]["id"].as_i64().is_some()); 338 + } 339 + 340 + #[tokio::test] 341 + async fn test_get_feed() { 342 + let pool = setup_test_db().await; 343 + insert_test_data(&pool).await; 344 + let app = make_app(pool); 345 + let (status, json) = get_json(&app, "/v1/feeds/1").await; 346 + assert_eq!(status, StatusCode::OK); 347 + assert_eq!(json["title"], "Example Feed"); 348 + assert_eq!(json["category"]["title"], "Tech"); 349 + } 350 + 351 + #[tokio::test] 352 + async fn test_get_feed_not_found() { 353 + let pool = setup_test_db().await; 354 + let app = make_app(pool); 355 + let (status, _) = get_json(&app, "/v1/feeds/999").await; 356 + assert_eq!(status, StatusCode::NOT_FOUND); 357 + } 358 + 359 + #[tokio::test] 360 + async fn test_create_feed() { 361 + let pool = setup_test_db().await; 362 + insert_test_data(&pool).await; 363 + let app = make_app(pool); 364 + let (status, json) = request_with_body( 365 + &app, 366 + "POST", 367 + "/v1/feeds", 368 + json!({"feed_url": "http://new.com/rss", "category_id": 1}), 369 + ) 370 + .await; 371 + assert_eq!(status, StatusCode::CREATED); 372 + assert_eq!(json["feed_url"], "http://new.com/rss"); 373 + } 374 + 375 + #[tokio::test] 376 + async fn test_delete_feed() { 377 + let pool = setup_test_db().await; 378 + insert_test_data(&pool).await; 379 + let app = make_app(pool); 380 + let (hk, hv) = auth_header(); 381 + let response = app 382 + .clone() 383 + .oneshot( 384 + Request::builder() 385 + .method("DELETE") 386 + .uri("/v1/feeds/1") 387 + .header(hk, hv) 388 + .body(Body::empty()) 389 + .unwrap(), 390 + ) 391 + .await 392 + .unwrap(); 393 + assert_eq!(response.status(), StatusCode::NO_CONTENT); 394 + } 395 + 396 + #[tokio::test] 397 + async fn test_feed_counters() { 398 + let pool = setup_test_db().await; 399 + insert_test_data(&pool).await; 400 + let app = make_app(pool); 401 + let (status, json) = get_json(&app, "/v1/feeds/counters").await; 402 + assert_eq!(status, StatusCode::OK); 403 + // Feed 1: 1 read, 1 unread; Feed 2: 0 read, 1 unread 404 + assert_eq!(json["reads"]["1"], 1); 405 + assert_eq!(json["unreads"]["1"], 1); 406 + assert_eq!(json["reads"]["2"], 0); 407 + assert_eq!(json["unreads"]["2"], 1); 408 + } 409 + 410 + // --------------------------------------------------------------------------- 411 + // Entries 412 + // --------------------------------------------------------------------------- 413 + 414 + #[tokio::test] 415 + async fn test_get_entries() { 416 + let pool = setup_test_db().await; 417 + insert_test_data(&pool).await; 418 + let app = make_app(pool); 419 + let (status, json) = get_json(&app, "/v1/entries").await; 420 + assert_eq!(status, StatusCode::OK); 421 + assert_eq!(json["total"], 3); 422 + assert_eq!(json["entries"].as_array().unwrap().len(), 3); 423 + } 424 + 425 + #[tokio::test] 426 + async fn test_get_entries_filter_unread() { 427 + let pool = setup_test_db().await; 428 + insert_test_data(&pool).await; 429 + let app = make_app(pool); 430 + let (status, json) = get_json(&app, "/v1/entries?status=unread").await; 431 + assert_eq!(status, StatusCode::OK); 432 + assert_eq!(json["total"], 2); 433 + } 434 + 435 + #[tokio::test] 436 + async fn test_get_entries_filter_starred() { 437 + let pool = setup_test_db().await; 438 + insert_test_data(&pool).await; 439 + let app = make_app(pool); 440 + let (status, json) = get_json(&app, "/v1/entries?starred=true").await; 441 + assert_eq!(status, StatusCode::OK); 442 + assert_eq!(json["total"], 1); 443 + assert_eq!(json["entries"][0]["starred"], true); 444 + } 445 + 446 + #[tokio::test] 447 + async fn test_get_entry() { 448 + let pool = setup_test_db().await; 449 + insert_test_data(&pool).await; 450 + let app = make_app(pool); 451 + let (status, json) = get_json(&app, "/v1/entries/1").await; 452 + assert_eq!(status, StatusCode::OK); 453 + assert_eq!(json["title"], "Article One"); 454 + assert_eq!(json["status"], "unread"); 455 + assert_eq!(json["feed"]["title"], "Example Feed"); 456 + } 457 + 458 + #[tokio::test] 459 + async fn test_update_entries_status() { 460 + let pool = setup_test_db().await; 461 + insert_test_data(&pool).await; 462 + let app = make_app(pool); 463 + let (status, _) = request_with_body( 464 + &app, 465 + "PUT", 466 + "/v1/entries", 467 + json!({"entry_ids": [1, 3], "status": "read"}), 468 + ) 469 + .await; 470 + assert_eq!(status, StatusCode::NO_CONTENT); 471 + 472 + // Verify 473 + let (_, json) = get_json(&app, "/v1/entries?status=unread").await; 474 + assert_eq!(json["total"], 0); 475 + } 476 + 477 + #[tokio::test] 478 + async fn test_toggle_bookmark() { 479 + let pool = setup_test_db().await; 480 + insert_test_data(&pool).await; 481 + let app = make_app(pool); 482 + // Entry 1 is not starred 483 + let status = put_no_body(&app, "/v1/entries/1/bookmark").await; 484 + assert_eq!(status, StatusCode::NO_CONTENT); 485 + 486 + // Verify it's now starred 487 + let (_, json) = get_json(&app, "/v1/entries/1").await; 488 + assert_eq!(json["starred"], true); 489 + 490 + // Toggle back 491 + let status = put_no_body(&app, "/v1/entries/1/bookmark").await; 492 + assert_eq!(status, StatusCode::NO_CONTENT); 493 + let (_, json) = get_json(&app, "/v1/entries/1").await; 494 + assert_eq!(json["starred"], false); 495 + } 496 + 497 + // --------------------------------------------------------------------------- 498 + // Mark all as read 499 + // --------------------------------------------------------------------------- 500 + 501 + #[tokio::test] 502 + async fn test_mark_feed_entries_as_read() { 503 + let pool = setup_test_db().await; 504 + insert_test_data(&pool).await; 505 + let app = make_app(pool); 506 + let status = put_no_body(&app, "/v1/feeds/1/mark-all-as-read").await; 507 + assert_eq!(status, StatusCode::NO_CONTENT); 508 + 509 + let (_, json) = get_json(&app, "/v1/feeds/1/entries?status=unread").await; 510 + assert_eq!(json["total"], 0); 511 + } 512 + 513 + #[tokio::test] 514 + async fn test_mark_category_entries_as_read() { 515 + let pool = setup_test_db().await; 516 + insert_test_data(&pool).await; 517 + let app = make_app(pool); 518 + let status = put_no_body(&app, "/v1/categories/1/mark-all-as-read").await; 519 + assert_eq!(status, StatusCode::NO_CONTENT); 520 + 521 + let (_, json) = get_json(&app, "/v1/categories/1/entries?status=unread").await; 522 + assert_eq!(json["total"], 0); 523 + } 524 + 525 + // --------------------------------------------------------------------------- 526 + // Version 527 + // --------------------------------------------------------------------------- 528 + 529 + #[tokio::test] 530 + async fn test_version() { 531 + let pool = setup_test_db().await; 532 + let app = make_app(pool); 533 + let (status, json) = get_json(&app, "/v1/version").await; 534 + assert_eq!(status, StatusCode::OK); 535 + assert!(json["version"].as_str().is_some()); 536 + } 537 + 538 + // --------------------------------------------------------------------------- 539 + // OPML export 540 + // --------------------------------------------------------------------------- 541 + 542 + #[tokio::test] 543 + async fn test_export_opml() { 544 + let pool = setup_test_db().await; 545 + insert_test_data(&pool).await; 546 + let app = make_app(pool); 547 + let (hk, hv) = auth_header(); 548 + let response = app 549 + .oneshot( 550 + Request::builder() 551 + .uri("/v1/export") 552 + .header(hk, hv) 553 + .body(Body::empty()) 554 + .unwrap(), 555 + ) 556 + .await 557 + .unwrap(); 558 + assert_eq!(response.status(), StatusCode::OK); 559 + let body = response.into_body().collect().await.unwrap().to_bytes(); 560 + let text = String::from_utf8(body.to_vec()).unwrap(); 561 + assert!(text.contains("<opml")); 562 + assert!(text.contains("Example Feed")); 563 + } 564 + 565 + // --------------------------------------------------------------------------- 566 + // Category entries/feeds 567 + // --------------------------------------------------------------------------- 568 + 569 + #[tokio::test] 570 + async fn test_get_category_entries() { 571 + let pool = setup_test_db().await; 572 + insert_test_data(&pool).await; 573 + let app = make_app(pool); 574 + let (status, json) = get_json(&app, "/v1/categories/1/entries").await; 575 + assert_eq!(status, StatusCode::OK); 576 + assert_eq!(json["total"], 2); // Feed 1 has 2 items, belongs to category 1 577 + } 578 + 579 + #[tokio::test] 580 + async fn test_get_category_feeds() { 581 + let pool = setup_test_db().await; 582 + insert_test_data(&pool).await; 583 + let app = make_app(pool); 584 + let (status, json) = get_json(&app, "/v1/categories/1/feeds").await; 585 + assert_eq!(status, StatusCode::OK); 586 + assert_eq!(json.as_array().unwrap().len(), 1); 587 + } 588 + 589 + // --------------------------------------------------------------------------- 590 + // Feed entries 591 + // --------------------------------------------------------------------------- 592 + 593 + #[tokio::test] 594 + async fn test_get_feed_entries() { 595 + let pool = setup_test_db().await; 596 + insert_test_data(&pool).await; 597 + let app = make_app(pool); 598 + let (status, json) = get_json(&app, "/v1/feeds/1/entries").await; 599 + assert_eq!(status, StatusCode::OK); 600 + assert_eq!(json["total"], 2); 601 + }