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