Phase 2: web front end

axum served from inside the daemon so it reads SQLite and the event bus
directly: browse feeds, read show notes, play with seeking, download and
delete files, mark read/flag, and edit feed settings.

Config is now hot-reloadable (Ctx.cfg behind RwLock<Arc<Config>>), so UI
edits apply without a daemon restart. Access is a shared token minted from
/dev/urandom, carried in a cookie because an <audio> element cannot send
headers. Show notes are untrusted feed HTML and are sanitized with ammonia
server-side.

read/flagged finally have a writer, which retention has needed since it
started ordering by them.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01RPyeapneuXrCdojsaiXGbe
This commit is contained in:
2026-09-10 00:55:16 +00:00
parent ed47e456d4
commit 74ec6e9281
9 changed files with 1391 additions and 52 deletions

211
Cargo.lock generated
View File

@@ -23,6 +23,18 @@ version = "0.2.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
[[package]]
name = "ammonia"
version = "4.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "dc6d763210e2eb7670d1a5183a08bebefa3f97db2a738a684f2ce00bd49f681d"
dependencies = [
"cssparser",
"html5ever",
"maplit",
"url",
]
[[package]]
name = "android_system_properties"
version = "0.1.6"
@@ -222,6 +234,7 @@ checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
dependencies = [
"axum-core",
"bytes",
"form_urlencoded",
"futures-util",
"http",
"http-body",
@@ -235,6 +248,9 @@ dependencies = [
"percent-encoding",
"pin-project-lite",
"serde_core",
"serde_json",
"serde_path_to_error",
"serde_urlencoded",
"sync_wrapper",
"tokio",
"tower",
@@ -590,6 +606,17 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
[[package]]
name = "cssparser"
version = "0.37.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8c9cdaae01d5ed7882b04d795e7f752f46ff52d2fa3b50a20d28c464510bba98"
dependencies = [
"dtoa-short",
"itoa",
"smallvec",
]
[[package]]
name = "darling"
version = "0.20.11"
@@ -813,6 +840,21 @@ dependencies = [
"windows-sys 0.52.0",
]
[[package]]
name = "dtoa"
version = "1.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c3cf4824e2d5f025c7b531afcb2325364084a16806f6d47fbc1f5fbd9960590"
[[package]]
name = "dtoa-short"
version = "0.3.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cd1511a7b6a56299bd043a9c167a6d2bfb37bf84a6dfceaba651168adfb43c87"
dependencies = [
"dtoa",
]
[[package]]
name = "dunce"
version = "1.0.5"
@@ -1206,6 +1248,16 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "html5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "46a1761807faccc9a19e86944bbf40610014066306f96edcdedc2fb714bcb7b8"
dependencies = [
"log",
"markup5ever",
]
[[package]]
name = "http"
version = "1.5.0"
@@ -1239,6 +1291,12 @@ dependencies = [
"pin-project-lite",
]
[[package]]
name = "http-range-header"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
[[package]]
name = "httparse"
version = "1.10.1"
@@ -1498,8 +1556,10 @@ checksum = "791930b43c0d5973160d90a8f3894509f2b273430f5c5c73b668636d0287c5c0"
name = "ipx"
version = "0.1.0"
dependencies = [
"ammonia",
"anyhow",
"atom_syndication",
"axum",
"chrono",
"clap",
"dirs",
@@ -1514,7 +1574,10 @@ dependencies = [
"serde",
"serde_json",
"tokio",
"tokio-stream",
"toml",
"tower",
"tower-http 0.7.1",
"tracing",
"tracing-subscriber",
"url",
@@ -2045,6 +2108,23 @@ version = "0.1.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154"
[[package]]
name = "maplit"
version = "1.0.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d"
[[package]]
name = "markup5ever"
version = "0.39.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7122d987ec5f704ee56f6e5b41a7d93722e9aae27ae07cafa4036c4d3f9757de"
dependencies = [
"log",
"tendril",
"web_atoms",
]
[[package]]
name = "matchers"
version = "0.2.0"
@@ -2161,6 +2241,12 @@ dependencies = [
"winapi",
]
[[package]]
name = "new_debug_unreachable"
version = "1.0.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "650eef8c711430f1a879fdd01d4745a7deea475becfb90269c06775983bbf086"
[[package]]
name = "nix"
version = "0.30.1"
@@ -2320,6 +2406,45 @@ version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
[[package]]
name = "phf"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c1562dc717473dbaa4c1f85a36410e03c047b2e7df7f45ee938fbef64ae7fadf"
dependencies = [
"phf_shared",
"serde",
]
[[package]]
name = "phf_codegen"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "49aa7f9d80421bca176ca8dbfebe668cc7a2684708594ec9f3c0db0805d5d6e1"
dependencies = [
"phf_generator",
"phf_shared",
]
[[package]]
name = "phf_generator"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "135ace3a761e564ec88c03a77317a7c6b80bb7f7135ef2544dbe054243b89737"
dependencies = [
"fastrand",
"phf_shared",
]
[[package]]
name = "phf_shared"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e57fef6bc5981e38c2ce2d63bfa546861309f875b8a75f092d1d54ae2d64f266"
dependencies = [
"siphasher",
]
[[package]]
name = "pin-project-lite"
version = "0.2.17"
@@ -2371,6 +2496,12 @@ dependencies = [
"zerocopy",
]
[[package]]
name = "precomputed-hash"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "925383efa346730478fb4838dbe9137d2a47675ad789c546d150a6e1dd4ab31c"
[[package]]
name = "proc-macro2"
version = "1.0.107"
@@ -2666,7 +2797,7 @@ dependencies = [
"tokio-rustls",
"tokio-util",
"tower",
"tower-http",
"tower-http 0.6.11",
"tower-service",
"url",
"wasm-bindgen",
@@ -3092,6 +3223,12 @@ version = "0.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e3a9fe34e3e7a50316060351f37187a3f546bce95496156754b601a5fa71b76e"
[[package]]
name = "siphasher"
version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
[[package]]
name = "size_format"
version = "1.0.2"
@@ -3151,6 +3288,30 @@ version = "1.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596"
[[package]]
name = "string_cache"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a18596f8c785a729f2819c0f6a7eae6ebeebdfffbfe4214ae6b087f690e31901"
dependencies = [
"new_debug_unreachable",
"parking_lot",
"phf_shared",
"precomputed-hash",
]
[[package]]
name = "string_cache_codegen"
version = "0.6.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "585635e46db231059f76c5849798146164652513eb9e8ab2685939dd90f29b69"
dependencies = [
"phf_generator",
"phf_shared",
"proc-macro2",
"quote",
]
[[package]]
name = "strsim"
version = "0.11.1"
@@ -3243,6 +3404,15 @@ version = "1.0.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
[[package]]
name = "tendril"
version = "0.5.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5fed54709c5b3a53d09bb1c113ea4f5ceafd1e772ddcb0030a82e1d56c087b08"
dependencies = [
"new_debug_unreachable",
]
[[package]]
name = "thiserror"
version = "1.0.69"
@@ -3474,6 +3644,7 @@ dependencies = [
"tokio",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@@ -3499,6 +3670,31 @@ dependencies = [
"url",
]
[[package]]
name = "tower-http"
version = "0.7.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08a05a66a4fdd61cbbe0a1d755ffe0ca6aba159dd4820936a0ff8a8278245b9c"
dependencies = [
"bitflags 2.13.1",
"bytes",
"futures-core",
"futures-util",
"http",
"http-body",
"http-body-util",
"http-range-header",
"httpdate",
"mime",
"mime_guess",
"percent-encoding",
"pin-project-lite",
"tokio",
"tokio-util",
"tower-layer",
"tower-service",
]
[[package]]
name = "tower-layer"
version = "0.3.3"
@@ -3517,6 +3713,7 @@ version = "0.1.44"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"log",
"pin-project-lite",
"tracing-attributes",
"tracing-core",
@@ -3790,6 +3987,18 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "web_atoms"
version = "0.2.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ba8b815c1b593dc0baf78dd0f4fc8fdb2de53198fb1163738093e9a311c33fb3"
dependencies = [
"phf",
"phf_codegen",
"string_cache",
"string_cache_codegen",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.9"

View File

@@ -4,8 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
ammonia = "4.1.4"
anyhow = "1.0.104"
atom_syndication = "0.12.10"
axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
clap = { version = "4.6.6", features = ["derive"] }
dirs = "7.0.0"
@@ -20,7 +22,10 @@ rusqlite = { version = "0.40.2", features = ["bundled"] }
serde = { version = "1.0.229", features = ["derive"] }
serde_json = "1.0.151"
tokio = { version = "1.53.1", features = ["rt-multi-thread", "macros", "fs", "io-util", "net", "sync", "time", "signal"] }
tokio-stream = { version = "0.1.19", features = ["sync"] }
toml = "1.1.5"
tower = { version = "0.5.3", features = ["util"] }
tower-http = { version = "0.7.1", features = ["fs"] }
tracing = "0.1.44"
tracing-subscriber = { version = "0.3.23", features = ["env-filter"] }
url = "2.5.8"

View File

@@ -17,6 +17,32 @@ The full design and step list live in the plan file at
see the step 7 entry)
- [x] **8. OPML + polish** — import/export, add/rm/status, tracing setup, systemd units, README.
### Phase 2 — web front end
Decided with Ray: axum serving plain HTML/JS (no WASM toolchain), running **inside the daemon**
process so it reads SQLite and the event bus directly, LAN-bindable with a shared token.
- [x] **9. Config hot-reload + web skeleton.** `Ctx.cfg` becomes `RwLock<Arc<Config>>` so the UI can
edit feeds without a daemon restart. `[web]` config section (enabled/bind/token, token
auto-generated and saved on first run). axum server started by `ipx daemon`, token checked by
middleware, `?token=` sets a cookie so `<audio>` requests authenticate too.
*Done when:* `ipx daemon` serves a page on the configured bind, and a wrong token gets 401.
- [x] **10. Browsing.** `/api/feeds`, `/api/feeds/:id/entries`, entry detail. Descriptions are
untrusted feed HTML — sanitized with `ammonia` before they reach the page.
*Done when:* the Glass Cannon feed's 131 entries browse and read correctly.
- [x] **11. Media actions.** Range-request audio streaming (`tower-http` ServeFile) so seeking
works, download-on-demand for a pending enclosure, delete a file, mark read/flagged.
*Done when:* an episode plays and seeks in a browser, and delete reaps the row.
- [x] **12. Feed configuration.** Add/remove feeds and edit folder, keywords, allow_explicit,
auto_download, max_new_per_check from the UI, written back to config.toml and hot-reloaded.
*Done when:* flipping allow_explicit in the UI takes effect on the next scan with no restart.
- [x] **13. Live progress + polish.** SSE from the existing broadcast bus so downloads show live.
README section, screenshot-free usage notes.
*Done when:* starting a fetch from the UI shows progress advancing without a reload.
Note: `read`/`flagged` finally get a writer here. Retention orders by them (see the step 5 entry),
and until now nothing set them.
## Smoke tests
1. `ipx add <feed>` + `ipx fetch` → file in `download_dir/<Show>/`, row in `enclosures`.
@@ -30,6 +56,55 @@ The full design and step list live in the plan file at
---
## 2026-09-10 — Phase 2: web front end (steps 9-13)
axum in the daemon process, plain HTML/JS in `web/index.html` (embedded with `include_str!`), no
WASM toolchain. One binary still.
**Config hot-reload.** `Ctx.cfg` is now `RwLock<Arc<Config>>`; `ctx.cfg()` hands out a snapshot, so
no guard is ever held across an await. The UI rewrites config.toml and calls `reload_cfg`, and a
running daemon picks the change up on its next scan. The CLI mutation commands (`add`/`rm`/`import`)
now clone a snapshot, edit, and save.
**Auth.** `[web] enabled/bind/token`; the token is minted from `/dev/urandom` on first run, written
back to config.toml, and the URL printed. `?token=` sets a year-long cookie — it has to be a cookie
because an `<audio>` element cannot send a header. Comparison is constant-time. An empty token makes
the server refuse to serve rather than serve open.
**Endpoints.** `/api/feeds` (GET/POST), `/api/feeds/{id}` (PATCH/DELETE),
`/api/feeds/{id}/entries`, `/api/entries/{feed}/{guid}/flags`, `/api/enclosures/{id}/download`,
`/api/enclosures/{id}` (DELETE), `/api/fetch`, `/api/events` (SSE off the existing broadcast bus),
`/media/{id}` (tower-http `ServeFile`, so Range works).
**`read`/`flagged` finally have a writer** — the UI sets them, and playing an episode marks it read.
Retention has ordered by these since step 5 with nothing to set them.
**Download-on-demand needed no new machinery**: requeue the row to `pending` and kick a scan, since
the queue is the table. That also un-skips an enclosure a filter rejected under older settings.
Verified against the live Glass Cannon feed, daemon on 127.0.0.1:8749:
- auth: no token 401, wrong token 401, right token 200 + cookie, API then works on the cookie alone;
`/media/1` unauthenticated is 401.
- browsing: 131 entries page with enclosures attached.
- **XSS**: injected `<script>alert(1)</script><img src=x onerror=alert(2)>` into a stored
description; it reaches the page as `<p>hi</p><img src="x">` — script tag and handler both gone.
- media: 200 with `accept-ranges: bytes`; `Range: bytes=1000000-1000999` returns 206 with the right
`content-range`, so seeking works.
- config: PATCH -> 204, written to config.toml, and the running daemon reports the new values with
no restart.
- SSE: 105 events for one download (101 progress), then `download_done`/`feed_done`/`scan_done`.
- flags: 204, unread count dropped 131 -> 130. Delete: file gone, row `reaped`/`path=NULL`.
`cargo test` 30/30.
**Caveats.** It is plain HTTP — on a LAN bind the token crosses the network in the clear, and a feed
URL may itself carry a credential (Patreon's does), which the `/api/feeds` response includes. A
reverse proxy with TLS is the answer if that matters. There is no per-user anything; the token is
all-or-nothing access.
---
## 2026-09-10 — Tested against a real feed (Patreon / Glass Cannon)
First run against a live subscriber feed: 131 items, 6.17 GB total, all `audio/mpeg`, no `<ttl>`.

View File

@@ -101,6 +101,39 @@ Events: `feed_start`, `feed_skip`, `feed_done`, `feed_error`, `progress`, `downl
Progress is throttled to whole percents. The stream is a broadcast, so a client attached to a busy
daemon also sees that daemon's other work.
## Web UI
```toml
[web]
enabled = true
bind = "0.0.0.0:8080" # 127.0.0.1:8080 by default
token = "" # generated and written back on first run
```
`ipx daemon` then serves it in the same process (`ipx daemon --web ADDR` overrides the bind for one
run). On first start it mints a token, saves it to config.toml, and prints the URL to open:
```
web ui token generated. Open:
http://0.0.0.0:8080/?token=1f4c…
```
`?token=` sets a year-long cookie, so you only paste it once per browser. Everything is behind that
token, including `/media/...` — a cookie rather than a header precisely because an `<audio>` element
cannot send headers.
Browse feeds, read show notes, play episodes in the browser (Range requests are served, so seeking
works), download or delete individual files, mark episodes read or flag them to keep, and edit a
feed's folder/keywords/explicit/auto-download/limit settings. Config edits are written to
config.toml and hot-reloaded — no daemon restart.
Show notes are feed-supplied HTML from an untrusted source; they are sanitized with `ammonia`
server-side before they reach the page.
**It is plain HTTP.** On a LAN bind, the token and everything else crosses the network in the
clear — and a feed URL can itself contain a credential (Patreon's, for one, carries an auth token).
Put it behind a reverse proxy with TLS if that matters to you.
## Running it as a service
`contrib/` has a systemd user unit for the daemon, and a timer plus one-shot service if you would

View File

@@ -5,18 +5,20 @@ use serde::{Deserialize, Serialize};
use std::collections::BTreeMap;
use std::path::{Path, PathBuf};
#[derive(Debug, Default, Deserialize, Serialize)]
#[derive(Debug, Default, Clone, Deserialize, Serialize)]
pub struct Config {
#[serde(default)]
pub general: General,
#[serde(default)]
pub torrent: Torrent,
#[serde(default)]
pub web: Web,
/// Keyed by feed id: the TOML table name, which replaces the old genHash(feedURL).
#[serde(default)]
pub feeds: BTreeMap<String, Feed>,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct General {
pub download_dir: PathBuf,
@@ -39,7 +41,7 @@ pub enum Organize {
Date,
}
#[derive(Debug, Deserialize, Serialize)]
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Torrent {
pub enabled: bool,
@@ -51,6 +53,32 @@ pub struct Torrent {
pub stall_mins: u64,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(default)]
pub struct Web {
pub enabled: bool,
/// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token.
pub bind: String,
/// Shared secret. Generated and written back on first run when left empty.
pub token: String,
}
impl Default for Web {
fn default() -> Self {
Self {
enabled: false,
bind: "127.0.0.1:8080".into(),
token: String::new(),
}
}
}
impl Web {
pub fn binds_publicly(&self) -> bool {
!self.bind.starts_with("127.") && !self.bind.starts_with("localhost")
}
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Feed {
pub url: String,

155
src/db.rs
View File

@@ -386,6 +386,15 @@ impl Db {
}
impl Db {
pub fn unread_count(&self, feed_id: &str) -> Result<i64> {
let conn = self.conn.lock().unwrap();
Ok(conn.query_row(
"SELECT count(*) FROM entries WHERE feed_id = ?1 AND read = 0",
[feed_id],
|r| r.get(0),
)?)
}
/// (pending, downloaded) across all feeds, for the status command.
pub fn counts(&self) -> Result<(i64, i64)> {
let conn = self.conn.lock().unwrap();
@@ -396,6 +405,152 @@ impl Db {
}
}
/// An entry plus its enclosures, for the web UI.
#[derive(Debug, serde::Serialize)]
pub struct EntryRow {
pub guid: String,
pub title: Option<String>,
pub link: Option<String>,
pub published: Option<i64>,
pub description: Option<String>,
pub read: bool,
pub flagged: bool,
pub enclosures: Vec<EncRow>,
}
#[derive(Debug, Clone, serde::Serialize)]
pub struct EncRow {
pub id: i64,
pub feed_id: String,
pub guid: String,
pub url: String,
pub mime: Option<String>,
pub length: Option<i64>,
pub path: Option<String>,
pub state: String,
pub last_error: Option<String>,
}
impl Db {
/// One page of a feed's entries, newest first, each with its enclosures attached.
pub fn entries(&self, feed_id: &str, offset: i64, limit: i64) -> Result<Vec<EntryRow>> {
let conn = self.conn.lock().unwrap();
let mut stmt = conn.prepare(
"SELECT guid, title, link, published, description, read, flagged
FROM entries WHERE feed_id = ?1
ORDER BY coalesce(published, first_seen) DESC, rowid DESC
LIMIT ?3 OFFSET ?2",
)?;
let mut rows: Vec<EntryRow> = stmt
.query_map(rusqlite::params![feed_id, offset, limit], |r| {
Ok(EntryRow {
guid: r.get(0)?,
title: r.get(1)?,
link: r.get(2)?,
published: r.get(3)?,
description: r.get(4)?,
read: r.get::<_, i64>(5)? != 0,
flagged: r.get::<_, i64>(6)? != 0,
enclosures: vec![],
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
if rows.is_empty() {
return Ok(rows);
}
// Only the guids on this page, so a feed with thousands of entries stays cheap.
let placeholders = std::iter::repeat_n("?", rows.len()).collect::<Vec<_>>().join(",");
let sql = format!(
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
FROM enclosures WHERE feed_id = ? AND guid IN ({placeholders}) ORDER BY id"
);
let mut params: Vec<&dyn rusqlite::ToSql> = Vec::with_capacity(rows.len() + 1);
params.push(&feed_id);
for row in &rows {
params.push(&row.guid);
}
let mut stmt = conn.prepare(&sql)?;
let encs = stmt
.query_map(params.as_slice(), |r| {
Ok(EncRow {
id: r.get(0)?,
feed_id: r.get(1)?,
guid: r.get(2)?,
url: r.get(3)?,
mime: r.get(4)?,
length: r.get(5)?,
path: r.get(6)?,
state: r.get(7)?,
last_error: r.get(8)?,
})
})?
.collect::<rusqlite::Result<Vec<_>>>()?;
for enc in encs {
if let Some(row) = rows.iter_mut().find(|r| r.guid == enc.guid) {
row.enclosures.push(enc);
}
}
Ok(rows)
}
pub fn enclosure(&self, id: i64) -> Result<Option<EncRow>> {
let conn = self.conn.lock().unwrap();
Ok(conn
.query_row(
"SELECT id, feed_id, guid, url, mime, length, path, state, last_error
FROM enclosures WHERE id = ?1",
[id],
|r| {
Ok(EncRow {
id: r.get(0)?,
feed_id: r.get(1)?,
guid: r.get(2)?,
url: r.get(3)?,
mime: r.get(4)?,
length: r.get(5)?,
path: r.get(6)?,
state: r.get(7)?,
last_error: r.get(8)?,
})
},
)
.optional()?)
}
/// `read` and `flagged` finally get a writer: retention orders by them.
pub fn set_entry_flag(&self, feed_id: &str, guid: &str, field: EntryFlag, on: bool) -> Result<()> {
let conn = self.conn.lock().unwrap();
let sql = match field {
EntryFlag::Read => "UPDATE entries SET read = ?3 WHERE feed_id = ?1 AND guid = ?2",
EntryFlag::Flagged => "UPDATE entries SET flagged = ?3 WHERE feed_id = ?1 AND guid = ?2",
};
conn.execute(sql, rusqlite::params![feed_id, guid, on as i64])?;
Ok(())
}
/// Puts an enclosure back in the queue so the next scan picks it up. This is how a
/// `skipped` verdict (from a filter that has since been changed) gets revisited.
pub fn requeue(&self, id: i64) -> Result<()> {
let conn = self.conn.lock().unwrap();
conn.execute(
"UPDATE enclosures SET state = 'pending', last_error = NULL
WHERE id = ?1 AND path IS NULL",
[id],
)?;
Ok(())
}
}
#[derive(Debug, Clone, Copy)]
pub enum EntryFlag {
Read,
Flagged,
}
/// Unix seconds. Everything time-shaped in the DB is stored this way.
pub fn now() -> i64 {
std::time::SystemTime::now()

View File

@@ -5,11 +5,13 @@ mod feed;
mod ipc;
mod retention;
mod torrent;
mod web;
use anyhow::{Context, Result};
use clap::{Parser, Subcommand};
use ipc::{Command as Cmd, Emitter, Event};
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
#[derive(Parser)]
@@ -64,24 +66,44 @@ enum Command {
/// Write subscriptions out as OPML
Export { file: PathBuf },
/// Run the scheduler and serve the control socket
Daemon,
Daemon {
/// Serve the web UI on this address, overriding [web] in the config
#[arg(long, value_name = "ADDR")]
web: Option<String>,
},
}
/// Everything a command needs. One per process.
struct Ctx {
cfg: config::Config,
db: db::Db,
client: reqwest::Client,
out: Emitter,
pub struct Ctx {
/// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks
/// up feed changes without a restart. Callers take a snapshot; no guard is ever held
/// across an await.
pub cfg: std::sync::RwLock<std::sync::Arc<config::Config>>,
pub db: db::Db,
pub client: reqwest::Client,
pub out: Emitter,
/// Started on first use: a BitTorrent session binds ports and starts a DHT, which is
/// rude to do for a config that has never seen a torrent.
torrents: tokio::sync::OnceCell<torrent::Torrents>,
pub torrents: tokio::sync::OnceCell<torrent::Torrents>,
}
impl Ctx {
pub fn cfg(&self) -> std::sync::Arc<config::Config> {
self.cfg.read().unwrap().clone()
}
/// Re-reads config.toml into the live snapshot.
pub fn reload_cfg(&self, path: &std::path::Path) -> Result<()> {
let fresh = config::Config::load(path)?;
*self.cfg.write().unwrap() = std::sync::Arc::new(fresh);
tracing::info!("config reloaded");
Ok(())
}
async fn torrents(&self) -> Result<&torrent::Torrents> {
let cfg = self.cfg();
self.torrents
.get_or_try_init(|| torrent::Torrents::new(&self.cfg))
.get_or_try_init(|| torrent::Torrents::new(&cfg))
.await
}
}
@@ -109,7 +131,7 @@ async fn main() -> Result<()> {
Command::Reap { dry_run } => Some(Cmd::Reap { dry_run: *dry_run }),
Command::Status => Some(Cmd::Status),
Command::List
| Command::Daemon
| Command::Daemon { .. }
| Command::Add { .. }
| Command::Rm { .. }
| Command::Import { .. }
@@ -123,7 +145,7 @@ async fn main() -> Result<()> {
}
let ctx = Ctx {
cfg,
cfg: std::sync::RwLock::new(std::sync::Arc::new(cfg)),
db,
client: reqwest::Client::builder()
.user_agent(concat!("ipx/", env!("CARGO_PKG_VERSION")))
@@ -134,7 +156,7 @@ async fn main() -> Result<()> {
match cli.command {
Command::List => list(&ctx, &config_path),
Command::Daemon => daemon(ctx).await,
Command::Daemon { web } => daemon(ctx, config_path, web).await,
Command::Add { url, folder, keywords } => {
add(ctx, &config_path, &url, folder, keywords).await
}
@@ -155,28 +177,29 @@ async fn run(ctx: &Ctx, cmd: Cmd) -> Result<()> {
Cmd::Reap { dry_run } => reap(ctx, dry_run, true),
Cmd::Status => {
let (pending, downloaded) = ctx.db.counts()?;
ctx.out.emit(Event::Status { feeds: ctx.cfg.feeds.len(), pending, downloaded });
ctx.out.emit(Event::Status { feeds: ctx.cfg().feeds.len(), pending, downloaded });
Ok(())
}
}
}
async fn daemon(ctx: Ctx) -> Result<()> {
let socket = ctx.cfg.general.socket.clone();
async fn daemon(ctx: Ctx, config_path: PathBuf, web_addr: Option<String>) -> Result<()> {
let socket = ctx.cfg().general.socket.clone();
if ipc::daemon_is_live(&socket).await {
anyhow::bail!("a daemon is already listening on {}", socket.display());
}
let (events, _) = broadcast::channel(1024);
let (tx_cmd, mut rx_cmd) = mpsc::channel::<Cmd>(64);
let ctx = Ctx { out: Emitter::socket(events.clone(), false), ..ctx };
let ctx = Arc::new(Ctx { out: Emitter::socket(events.clone(), false), ..ctx });
let server = tokio::spawn(ipc::serve(socket.clone(), events, tx_cmd));
let web = start_web(&ctx, &config_path, web_addr, &tx_cmd, &events).await?;
let server = tokio::spawn(ipc::serve(socket.clone(), events.clone(), tx_cmd));
// One command at a time: the queue is what keeps two scans from overlapping.
let mut ticker = tokio::time::interval(std::time::Duration::from_secs(60));
ticker.set_missed_tick_behavior(tokio::time::MissedTickBehavior::Skip);
tracing::info!(feeds = ctx.cfg.feeds.len(), "daemon started");
tracing::info!(feeds = ctx.cfg().feeds.len(), "daemon started");
loop {
tokio::select! {
@@ -197,11 +220,61 @@ async fn daemon(ctx: Ctx) -> Result<()> {
}
server.abort();
if let Some(w) = web {
w.abort();
}
let _ = std::fs::remove_file(&socket);
tracing::info!("daemon stopped");
Ok(())
}
/// Starts the web UI when it is switched on, minting and saving a token if there is none.
async fn start_web(
ctx: &Arc<Ctx>,
config_path: &std::path::Path,
web_addr: Option<String>,
cmds: &mpsc::Sender<Cmd>,
events: &broadcast::Sender<Event>,
) -> Result<Option<tokio::task::JoinHandle<()>>> {
let cfg = ctx.cfg();
let enabled = cfg.web.enabled || web_addr.is_some();
if !enabled {
return Ok(None);
}
let bind = web_addr.unwrap_or_else(|| cfg.web.bind.clone());
if cfg.web.token.is_empty() {
let mut fresh = (*cfg).clone();
fresh.web.enabled = true;
fresh.web.bind = bind.clone();
fresh.web.token = web::generate_token();
fresh.save(config_path)?;
ctx.reload_cfg(config_path)?;
println!("web ui token generated. Open:\n http://{bind}/?token={}", fresh.web.token);
} else {
println!(
"web ui at http://{bind}/?token={}",
ctx.cfg().web.token
);
}
if ctx.cfg().web.binds_publicly() {
tracing::warn!(bind, "web ui is reachable off this machine; the token is all that guards it");
}
let state = web::WebState {
ctx: ctx.clone(),
config_path: config_path.to_path_buf(),
cmds: cmds.clone(),
events: events.clone(),
};
Ok(Some(tokio::spawn(async move {
if let Err(e) = web::serve(state, &bind).await {
tracing::error!(error = ?e, "web ui stopped");
}
})))
}
async fn shutdown() {
use tokio::signal::unix::{SignalKind, signal};
let mut term = match signal(SignalKind::terminate()) {
@@ -216,25 +289,27 @@ async fn shutdown() {
/// Subscribes to one feed, naming it from its own title.
async fn add(
mut ctx: Ctx,
ctx: Ctx,
config_path: &std::path::Path,
url: &str,
folder: Option<String>,
keywords: Vec<String>,
) -> Result<()> {
if let Some((id, _)) = ctx.cfg.feeds.iter().find(|(_, f)| f.url == url) {
let mut cfg = (*ctx.cfg()).clone();
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == url) {
anyhow::bail!("already subscribed as {id:?}");
}
let id = add_one(&mut ctx, url, folder, keywords).await?;
ctx.cfg.save(config_path)?;
let id = add_one(&ctx, &mut cfg, url, folder, keywords).await?;
cfg.save(config_path)?;
println!("added {id}");
Ok(())
}
/// Returns the new feed id. The title needs a fetch, so a feed that cannot be reached is
/// still added -- under a slug derived from its URL -- rather than refused.
async fn add_one(
ctx: &mut Ctx,
pub async fn add_one(
ctx: &Ctx,
cfg: &mut config::Config,
url: &str,
folder: Option<String>,
keywords: Vec<String>,
@@ -262,8 +337,8 @@ async fn add_one(
}
};
let id = config::unique_slug(&title, &ctx.cfg.feeds);
ctx.cfg.feeds.insert(id.clone(), probe);
let id = config::unique_slug(&title, &cfg.feeds);
cfg.feeds.insert(id.clone(), probe);
Ok(id)
}
@@ -275,17 +350,19 @@ fn url_stem(url: &str) -> String {
.unwrap_or_else(|| url.to_owned())
}
fn rm(mut ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
if ctx.cfg.feeds.remove(feed).is_none() {
fn rm(ctx: Ctx, config_path: &std::path::Path, feed: &str) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone();
if cfg.feeds.remove(feed).is_none() {
anyhow::bail!("no feed with id {feed:?}");
}
ctx.cfg.save(config_path)?;
cfg.save(config_path)?;
// State and files stay: re-adding the feed should not re-download its back catalogue.
println!("removed {feed}; downloads and history kept");
Ok(())
}
async fn import(mut ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
async fn import(ctx: Ctx, config_path: &std::path::Path, file: &std::path::Path) -> Result<()> {
let mut cfg = (*ctx.cfg()).clone();
let text = std::fs::read_to_string(file)
.with_context(|| format!("reading {}", file.display()))?;
let doc = opml::OPML::from_str(&text).map_err(|e| anyhow::anyhow!("parsing OPML: {e}"))?;
@@ -295,12 +372,12 @@ async fn import(mut ctx: Ctx, config_path: &std::path::Path, file: &std::path::P
let mut added = 0;
for (title, url) in found {
if ctx.cfg.feeds.values().any(|f| f.url == url) {
if cfg.feeds.values().any(|f| f.url == url) {
continue;
}
// Name it from the OPML title rather than refetching every feed.
let id = config::unique_slug(&title, &ctx.cfg.feeds);
ctx.cfg.feeds.insert(
let id = config::unique_slug(&title, &cfg.feeds);
cfg.feeds.insert(
id.clone(),
config::Feed {
url,
@@ -317,7 +394,7 @@ async fn import(mut ctx: Ctx, config_path: &std::path::Path, file: &std::path::P
println!("added {id}");
added += 1;
}
ctx.cfg.save(config_path)?;
cfg.save(config_path)?;
println!("{added} feed(s) imported");
Ok(())
}
@@ -339,7 +416,7 @@ fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
title: Some("ipx subscriptions".into()),
..Default::default()
});
for (id, feed) in &ctx.cfg.feeds {
for (id, feed) in &ctx.cfg().feeds {
let title = ctx
.db
.feed_summary(id)
@@ -350,16 +427,17 @@ fn export(ctx: &Ctx, file: &std::path::Path) -> Result<()> {
}
let xml = doc.to_string().map_err(|e| anyhow::anyhow!("writing OPML: {e}"))?;
std::fs::write(file, xml).with_context(|| format!("writing {}", file.display()))?;
println!("exported {} feed(s) to {}", ctx.cfg.feeds.len(), file.display());
println!("exported {} feed(s) to {}", ctx.cfg().feeds.len(), file.display());
Ok(())
}
fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> {
if ctx.cfg.feeds.is_empty() {
let cfg = ctx.cfg();
if cfg.feeds.is_empty() {
println!("No feeds configured in {}", config_path.display());
return Ok(());
}
for (id, feed) in &ctx.cfg.feeds {
for (id, feed) in &cfg.feeds {
let s = ctx.db.feed_summary(id)?;
println!("{id} {}", s.title.as_deref().unwrap_or("-"));
println!(" url {}", feed.url);
@@ -376,7 +454,7 @@ fn list(ctx: &Ctx, config_path: &std::path::Path) -> Result<()> {
/// deleted, but must not emit the terminal ReapDone, or a client waiting on its `fetch`
/// would stop reading before the scan had even started.
fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
let r = retention::run(&ctx.cfg, &ctx.db, dry_run)?;
let r = retention::run(&ctx.cfg(), &ctx.db, dry_run)?;
for c in r.aged_out.iter().chain(r.over_quota.iter()) {
ctx.out.emit(Event::Reaped {
path: c.path.clone(),
@@ -393,15 +471,15 @@ fn reap(ctx: &Ctx, dry_run: bool, standalone: bool) -> Result<()> {
}
async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
let cfg = ctx.cfg();
if let Some(id) = only
&& !ctx.cfg.feeds.contains_key(id)
&& !cfg.feeds.contains_key(id)
{
anyhow::bail!("no feed with id {id:?}");
}
let mut scanned = 0;
for (id, feed_cfg) in ctx
.cfg
for (id, feed_cfg) in cfg
.feeds
.iter()
.filter(|(id, _)| only.is_none_or(|o| o == *id))
@@ -410,7 +488,7 @@ async fn fetch(ctx: &Ctx, only: Option<&str>, force: bool) -> Result<()> {
// TTL: the feed's own <ttl> wins when it is longer than our poll interval.
if !force && let Some(last) = state.last_checked {
let wait = state.ttl_mins.unwrap_or(0).max(ctx.cfg.general.interval_mins) * 60;
let wait = state.ttl_mins.unwrap_or(0).max(cfg.general.interval_mins) * 60;
let due = last + wait as i64;
if due > db::now() {
ctx.out.emit(Event::FeedSkip {
@@ -507,12 +585,13 @@ async fn scan_one(
let budget = feed_cfg.max_new_per_check.unwrap_or(usize::MAX);
if feed_cfg.auto_download && budget > 0 {
let folder = download::folder_for(&ctx.cfg, id, feed_cfg, parsed.title.as_deref());
let dest_dir = ctx.cfg.general.download_dir.join(&folder);
let cfg = ctx.cfg();
let folder = download::folder_for(&cfg, id, feed_cfg, parsed.title.as_deref());
let dest_dir = cfg.general.download_dir.join(&folder);
for item in ctx.db.pending(id, budget)? {
if download::looks_like_torrent(&item.url, item.mime.as_deref()) {
if !ctx.cfg.torrent.enabled {
if !ctx.cfg().torrent.enabled {
ctx.db.mark_enclosure(&item.url, "skipped", Some("torrents disabled"))?;
ctx.out.emit(Event::TorrentDeferred {
feed: id.to_string(),
@@ -603,7 +682,8 @@ async fn fetch_one(
// Throttled to whole percents, as the original's lastDLStepSize guard did.
let mut last_pct = -1i64;
let name = download::filename_for(url, None);
let got = download::download(&ctx.client, &ctx.cfg, feed_cfg, url, |done, total| {
let cfg = ctx.cfg();
let got = download::download(&ctx.client, &cfg, feed_cfg, url, |done, total| {
if let Some(t) = total.filter(|t| *t > 0) {
let pct = (done * 100 / t) as i64;
if pct > last_pct {
@@ -624,7 +704,7 @@ async fn fetch_one(
// The MIME lied. Hand the URL to the torrent session instead of filing a .torrent
// as if it were an episode.
let _ = tokio::fs::remove_file(&got.tmp).await;
if !ctx.cfg.torrent.enabled {
if !ctx.cfg().torrent.enabled {
ctx.db.mark_enclosure(url, "skipped", Some("torrents disabled"))?;
anyhow::bail!("body is a torrent and torrents are disabled");
}
@@ -646,9 +726,10 @@ async fn torrent_one(
) -> Result<(PathBuf, u64)> {
let name = download::filename_for(url, None);
let mut last_pct = -1i64;
let cfg = ctx.cfg();
ctx.torrents()
.await?
.fetch(&ctx.cfg, url, dest_dir, |done, total| {
.fetch(&cfg, url, dest_dir, |done, total| {
if total > 0 {
let pct = (done * 100 / total) as i64;
if pct > last_pct {

437
src/web.rs Normal file
View File

@@ -0,0 +1,437 @@
//! Web front end. Runs inside the daemon so it reads SQLite and the event bus directly.
use anyhow::{Context, Result};
use axum::{
Json, Router,
extract::{Path, Query, Request, State},
http::{StatusCode, header},
middleware::{self, Next},
response::{
Html, IntoResponse, Response,
sse::{Event as SseEvent, Sse},
},
routing::{delete, get, patch, post},
};
use futures_util::StreamExt;
use serde::Deserialize;
use tokio_stream::wrappers::BroadcastStream;
use tower::ServiceExt;
use tower_http::services::ServeFile;
use serde::Serialize;
use std::path::PathBuf;
use std::sync::Arc;
use tokio::sync::{broadcast, mpsc};
use crate::Ctx;
use crate::ipc::{Command, Event};
const COOKIE: &str = "ipx_token";
#[derive(Clone)]
pub struct WebState {
pub ctx: Arc<Ctx>,
pub config_path: PathBuf,
pub cmds: mpsc::Sender<Command>,
pub events: broadcast::Sender<Event>,
}
/// A 32-hex-character shared secret, generated when config.toml has none.
///
/// ponytail: /dev/urandom rather than a CSPRNG crate -- 16 bytes, once, on a Unix-only
/// binary. Falls back to the clock only if urandom is somehow unreadable, which would be a
/// weak token, so that case is logged loudly.
pub fn generate_token() -> String {
use std::io::Read;
let mut bytes = [0u8; 16];
match std::fs::File::open("/dev/urandom").and_then(|mut f| f.read_exact(&mut bytes)) {
Ok(()) => {}
Err(e) => {
tracing::error!(error = %e, "could not read /dev/urandom; token is NOT secure");
let n = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_nanos() as u64)
.unwrap_or(0);
bytes[..8].copy_from_slice(&n.to_le_bytes());
}
}
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
pub fn router(state: WebState) -> Router {
Router::new()
.route("/", get(index))
.route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries))
.route("/api/entries/{feed_id}/{guid}/flags", post(set_flags))
.route("/api/enclosures/{id}/download", post(download_now))
.route("/api/enclosures/{id}", delete(delete_file))
.route("/api/fetch", post(fetch_now))
.route("/api/events", get(events))
.route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth))
.with_state(state)
}
pub async fn serve(state: WebState, bind: &str) -> Result<()> {
let listener = tokio::net::TcpListener::bind(bind)
.await
.with_context(|| format!("binding {bind}"))?;
tracing::info!(bind, "web ui listening");
axum::serve(listener, router(state))
.await
.context("serving the web ui")
}
/// Token in `?token=` (which then sets a cookie) or in the cookie itself.
///
/// It has to be a cookie rather than a header: an `<audio src>` request is issued by the
/// browser, and there is no way to attach a header to it.
async fn auth(State(state): State<WebState>, req: Request, next: Next) -> Response {
let expected = state.ctx.cfg().web.token.clone();
if expected.is_empty() {
// Refuse to serve rather than serve unauthenticated.
return (StatusCode::INTERNAL_SERVER_ERROR, "no web token configured").into_response();
}
let from_query = req.uri().query().and_then(|q| {
q.split('&')
.find_map(|kv| kv.strip_prefix("token=").map(str::to_owned))
});
let from_cookie = req
.headers()
.get(header::COOKIE)
.and_then(|v| v.to_str().ok())
.and_then(|c| {
c.split(';')
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(str::to_owned))
});
let supplied = from_query.clone().or(from_cookie);
if !supplied.is_some_and(|t| constant_time_eq(&t, &expected)) {
return (StatusCode::UNAUTHORIZED, "bad or missing token").into_response();
}
let mut resp = next.run(req).await;
if from_query.is_some() {
// Remember it so the rest of the page (and the audio element) authenticates.
if let Ok(v) = header::HeaderValue::from_str(&format!(
"{COOKIE}={expected}; Path=/; SameSite=Lax; Max-Age=31536000"
)) {
resp.headers_mut().insert(header::SET_COOKIE, v);
}
}
resp
}
/// Compares without leaking length or position through timing.
fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() {
return false;
}
a.iter().zip(b).fold(0u8, |acc, (x, y)| acc | (x ^ y)) == 0
}
async fn index() -> Html<&'static str> {
Html(include_str!("../web/index.html"))
}
#[derive(Serialize)]
struct FeedRow {
id: String,
url: String,
title: Option<String>,
folder: Option<String>,
keywords: Vec<String>,
allow_explicit: bool,
auto_download: bool,
max_new_per_check: Option<usize>,
last_checked: Option<i64>,
last_error: Option<String>,
entries: i64,
downloaded: i64,
unread: i64,
}
async fn feeds(State(state): State<WebState>) -> Result<Json<Vec<FeedRow>>, ApiError> {
let cfg = state.ctx.cfg();
let mut out = Vec::with_capacity(cfg.feeds.len());
for (id, feed) in &cfg.feeds {
let s = state.ctx.db.feed_summary(id)?;
out.push(FeedRow {
id: id.clone(),
url: feed.url.clone(),
title: s.title,
folder: feed.folder.clone(),
keywords: feed.keywords.clone(),
allow_explicit: feed.allow_explicit,
auto_download: feed.auto_download,
max_new_per_check: feed.max_new_per_check,
last_checked: s.last_checked,
last_error: s.last_error,
entries: s.entries,
downloaded: s.downloaded,
unread: state.ctx.db.unread_count(id)?,
});
}
Ok(Json(out))
}
/// Turns anyhow errors into a 500 with a readable body.
pub struct ApiError(anyhow::Error);
impl<E: Into<anyhow::Error>> From<E> for ApiError {
fn from(e: E) -> Self {
Self(e.into())
}
}
impl IntoResponse for ApiError {
fn into_response(self) -> Response {
tracing::warn!(error = ?self.0, "api error");
(StatusCode::INTERNAL_SERVER_ERROR, format!("{:#}", self.0)).into_response()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn token_comparison_rejects_mismatches_and_length_differences() {
assert!(constant_time_eq("abc123", "abc123"));
assert!(!constant_time_eq("abc123", "abc124"));
assert!(!constant_time_eq("abc", "abc123"));
assert!(!constant_time_eq("", "abc"));
assert!(constant_time_eq("", ""));
}
#[test]
fn generated_tokens_are_32_hex_chars_and_not_repeated() {
let a = generate_token();
let b = generate_token();
assert_eq!(a.len(), 32);
assert!(a.chars().all(|c| c.is_ascii_hexdigit()));
assert_ne!(a, b);
}
}
#[derive(Deserialize)]
struct Page {
#[serde(default)]
offset: i64,
#[serde(default = "fifty")]
limit: i64,
}
fn fifty() -> i64 {
50
}
async fn entries(
State(state): State<WebState>,
Path(id): Path<String>,
Query(page): Query<Page>,
) -> Result<Json<Vec<crate::db::EntryRow>>, ApiError> {
let mut rows = state.ctx.db.entries(&id, page.offset, page.limit.clamp(1, 200))?;
// Feed HTML is untrusted: it reaches the page only after ammonia has been through it.
for row in &mut rows {
if let Some(d) = &row.description {
row.description = Some(ammonia::clean(d));
}
}
Ok(Json(rows))
}
#[derive(Deserialize)]
struct NewFeed {
url: String,
#[serde(default)]
folder: Option<String>,
#[serde(default)]
keywords: Vec<String>,
}
async fn add_feed(
State(state): State<WebState>,
Json(body): Json<NewFeed>,
) -> Result<Json<serde_json::Value>, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
if let Some((id, _)) = cfg.feeds.iter().find(|(_, f)| f.url == body.url) {
return Ok(Json(serde_json::json!({ "id": id, "existing": true })));
}
let id = crate::add_one(&state.ctx, &mut cfg, &body.url, body.folder, body.keywords).await?;
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(Json(serde_json::json!({ "id": id, "existing": false })))
}
/// Only the fields that are present are changed.
#[derive(Deserialize)]
struct FeedPatch {
folder: Option<Option<String>>,
keywords: Option<Vec<String>>,
allow_explicit: Option<bool>,
auto_download: Option<bool>,
max_new_per_check: Option<Option<usize>>,
}
async fn patch_feed(
State(state): State<WebState>,
Path(id): Path<String>,
Json(body): Json<FeedPatch>,
) -> Result<StatusCode, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
let feed = cfg
.feeds
.get_mut(&id)
.ok_or_else(|| anyhow::anyhow!("no feed with id {id:?}"))?;
if let Some(v) = body.folder {
feed.folder = v.filter(|s| !s.trim().is_empty());
}
if let Some(v) = body.keywords {
feed.keywords = v.into_iter().filter(|k| !k.trim().is_empty()).collect();
}
if let Some(v) = body.allow_explicit {
feed.allow_explicit = v;
}
if let Some(v) = body.auto_download {
feed.auto_download = v;
}
if let Some(v) = body.max_new_per_check {
feed.max_new_per_check = v;
}
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(StatusCode::NO_CONTENT)
}
async fn remove_feed(
State(state): State<WebState>,
Path(id): Path<String>,
) -> Result<StatusCode, ApiError> {
let mut cfg = (*state.ctx.cfg()).clone();
if cfg.feeds.remove(&id).is_none() {
return Err(anyhow::anyhow!("no feed with id {id:?}").into());
}
// Downloads and history stay, so re-adding does not re-pull the back catalogue.
cfg.save(&state.config_path)?;
state.ctx.reload_cfg(&state.config_path)?;
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
struct Flags {
read: Option<bool>,
flagged: Option<bool>,
}
async fn set_flags(
State(state): State<WebState>,
Path((feed_id, guid)): Path<(String, String)>,
Json(body): Json<Flags>,
) -> Result<StatusCode, ApiError> {
use crate::db::EntryFlag;
if let Some(v) = body.read {
state.ctx.db.set_entry_flag(&feed_id, &guid, EntryFlag::Read, v)?;
}
if let Some(v) = body.flagged {
state.ctx.db.set_entry_flag(&feed_id, &guid, EntryFlag::Flagged, v)?;
}
Ok(StatusCode::NO_CONTENT)
}
/// Puts one enclosure back in the queue and kicks a scan of its feed. The queue is the
/// table, so this is all it takes -- including for something a filter once skipped.
async fn download_now(
State(state): State<WebState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id)?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if enc.path.is_some() {
return Ok(StatusCode::NO_CONTENT); // Already here.
}
state.ctx.db.requeue(id)?;
let _ = state
.cmds
.send(Command::Fetch { feed: Some(enc.feed_id), force: true })
.await;
Ok(StatusCode::ACCEPTED)
}
async fn delete_file(
State(state): State<WebState>,
Path(id): Path<i64>,
) -> Result<StatusCode, ApiError> {
let enc = state
.ctx
.db
.enclosure(id)?
.ok_or_else(|| anyhow::anyhow!("no enclosure {id}"))?;
if let Some(path) = &enc.path
&& let Err(e) = std::fs::remove_file(path)
&& e.kind() != std::io::ErrorKind::NotFound
{
return Err(e.into());
}
// The row survives as 'reaped', which is what stops the next scan re-downloading it.
state.ctx.db.mark_reaped(id)?;
state.events.send(Event::Reaped {
path: enc.path.unwrap_or_default(),
bytes: enc.length.unwrap_or(0).max(0) as u64,
}).ok();
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
struct FetchBody {
#[serde(default)]
feed: Option<String>,
#[serde(default)]
force: bool,
}
async fn fetch_now(
State(state): State<WebState>,
Json(body): Json<FetchBody>,
) -> Result<StatusCode, ApiError> {
state
.cmds
.send(Command::Fetch { feed: body.feed, force: body.force })
.await
.map_err(|_| anyhow::anyhow!("the daemon is not accepting commands"))?;
Ok(StatusCode::ACCEPTED)
}
/// The same broadcast the socket clients read, as server-sent events.
async fn events(State(state): State<WebState>) -> Sse<impl futures_util::Stream<Item = Result<SseEvent, std::convert::Infallible>>> {
let stream = BroadcastStream::new(state.events.subscribe()).filter_map(|ev| async move {
let ev = ev.ok()?;
Some(Ok(SseEvent::default().data(serde_json::to_string(&ev).ok()?)))
});
Sse::new(stream).keep_alive(axum::response::sse::KeepAlive::default())
}
/// Audio, served by ServeFile so Range requests work and the player can seek.
async fn media(
State(state): State<WebState>,
Path(id): Path<i64>,
req: Request,
) -> Response {
let Ok(Some(enc)) = state.ctx.db.enclosure(id) else {
return (StatusCode::NOT_FOUND, "no such enclosure").into_response();
};
let Some(path) = enc.path else {
return (StatusCode::NOT_FOUND, "not downloaded").into_response();
};
match ServeFile::new(path).oneshot(req).await {
Ok(r) => r.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

316
web/index.html Normal file
View File

@@ -0,0 +1,316 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>ipx</title>
<style>
:root {
--bg: #14161a; --panel: #1c1f26; --panel2: #23272f; --line: #2e333d;
--fg: #e6e8ec; --dim: #9aa2b1; --accent: #6ea8fe; --good: #5fd08a;
--bad: #f4776a;
}
* { box-sizing: border-box; }
body {
margin: 0; background: var(--bg); color: var(--fg);
font: 15px/1.5 system-ui, -apple-system, "Segoe UI", sans-serif;
}
header {
display: flex; align-items: center; gap: 12px; padding: 10px 16px;
background: var(--panel); border-bottom: 1px solid var(--line);
position: sticky; top: 0; z-index: 5;
}
header h1 { font-size: 16px; margin: 0; letter-spacing: .06em; text-transform: uppercase; color: var(--dim); }
#status { margin-left: auto; color: var(--dim); font-size: 13px; min-height: 1em; }
button {
background: var(--panel2); color: var(--fg); border: 1px solid var(--line);
border-radius: 6px; padding: 5px 10px; cursor: pointer; font-size: 13px;
}
button:hover { border-color: var(--accent); }
button.primary { background: var(--accent); color: #10131a; border-color: var(--accent); font-weight: 600; }
button.danger:hover { border-color: var(--bad); color: var(--bad); }
main { display: grid; grid-template-columns: 300px 1fr; min-height: calc(100vh - 49px); }
#feeds { background: var(--panel); border-right: 1px solid var(--line); padding: 8px; }
.feed {
padding: 8px 10px; border-radius: 6px; cursor: pointer; margin-bottom: 2px;
}
.feed:hover { background: var(--panel2); }
.feed.sel { background: var(--panel2); box-shadow: inset 3px 0 0 var(--accent); }
.feed .name { display: flex; gap: 8px; align-items: baseline; }
.feed .name b { font-weight: 600; font-size: 14px; flex: 1; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.pill { background: var(--accent); color: #10131a; border-radius: 10px; padding: 0 7px; font-size: 11px; font-weight: 700; }
.feed small, .meta { color: var(--dim); font-size: 12px; }
.err { color: var(--bad); font-size: 12px; }
#content { padding: 16px 20px; max-width: 900px; }
.entry { border: 1px solid var(--line); border-radius: 8px; margin-bottom: 8px; background: var(--panel); }
.entry.unread { border-left: 3px solid var(--accent); }
.head { padding: 10px 12px; cursor: pointer; display: flex; gap: 10px; align-items: baseline; }
.head h3 { margin: 0; font-size: 15px; font-weight: 600; flex: 1; }
.body { padding: 0 12px 12px; border-top: 1px solid var(--line); }
.desc { color: #cfd4dd; font-size: 14px; overflow-wrap: anywhere; }
.desc img { max-width: 100%; height: auto; }
.desc a { color: var(--accent); }
audio { width: 100%; margin: 10px 0; }
.row { display: flex; gap: 8px; flex-wrap: wrap; align-items: center; margin-top: 8px; }
.bar { height: 4px; background: var(--panel2); border-radius: 2px; overflow: hidden; margin-top: 6px; }
.bar i { display: block; height: 100%; background: var(--accent); width: 0; transition: width .2s; }
.state { font-size: 11px; text-transform: uppercase; letter-spacing: .05em; color: var(--dim); }
.state.done { color: var(--good); }
.state.error { color: var(--bad); }
form.settings { display: grid; gap: 8px; margin-top: 10px; }
form.settings label { display: grid; gap: 3px; font-size: 12px; color: var(--dim); }
input[type=text], input[type=number] {
background: var(--bg); border: 1px solid var(--line); color: var(--fg);
border-radius: 6px; padding: 6px 8px; font-size: 14px; width: 100%;
}
.checks { display: flex; gap: 16px; font-size: 13px; color: var(--fg); }
.checks label { flex-direction: row; align-items: center; gap: 6px; color: var(--fg); }
.panel { background: var(--panel); border: 1px solid var(--line); border-radius: 8px; padding: 12px; margin-bottom: 12px; }
h2 { font-size: 17px; margin: 0 0 2px; }
.empty { color: var(--dim); padding: 24px 0; }
@media (max-width: 700px) {
main { grid-template-columns: 1fr; }
#feeds { border-right: 0; border-bottom: 1px solid var(--line); }
}
</style>
</head>
<body>
<header>
<h1>ipx</h1>
<button id="fetchAll">Scan all</button>
<button id="showAdd">+ Feed</button>
<span id="status"></span>
</header>
<main>
<nav id="feeds"></nav>
<section id="content"><p class="empty">Pick a feed.</p></section>
</main>
<script>
const $ = (s, r = document) => r.querySelector(s);
const api = async (url, opts) => {
const r = await fetch(url, { headers: { 'Content-Type': 'application/json' }, ...opts });
if (!r.ok) throw new Error((await r.text()) || r.status);
return r.status === 204 ? null : r.json().catch(() => null);
};
const esc = s => (s ?? '').replace(/[&<>"]/g, c => ({ '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;' }[c]));
const when = t => t ? new Date(t * 1000).toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric' }) : '';
const mb = n => n ? (n / 1048576).toFixed(1) + ' MB' : '';
const say = (m) => { $('#status').textContent = m; };
let feeds = [], current = null, entries = [], open = new Set();
async function loadFeeds() {
feeds = await api('/api/feeds');
const nav = $('#feeds');
nav.innerHTML = '';
if (!feeds.length) { nav.innerHTML = '<p class="empty" style="padding:10px">No feeds yet.</p>'; return; }
for (const f of feeds) {
const el = document.createElement('div');
el.className = 'feed' + (current === f.id ? ' sel' : '');
el.innerHTML = `<div class="name"><b>${esc(f.title || f.id)}</b>` +
(f.unread ? `<span class="pill">${f.unread}</span>` : '') + `</div>` +
`<small>${f.entries} entries &middot; ${f.downloaded} downloaded</small>` +
(f.last_error ? `<div class="err">${esc(f.last_error)}</div>` : '');
el.onclick = () => selectFeed(f.id);
nav.appendChild(el);
}
}
async function selectFeed(id) {
current = id; open.clear();
await loadFeeds();
await loadEntries();
}
async function loadEntries() {
const f = feeds.find(x => x.id === current);
if (!f) return;
entries = await api(`/api/feeds/${encodeURIComponent(current)}/entries?limit=100`);
render(f);
}
function render(f) {
const c = $('#content');
c.innerHTML = `
<div class="panel">
<h2>${esc(f.title || f.id)}</h2>
<div class="meta">${esc(f.url)}</div>
<div class="row">
<button onclick="scan('${f.id}')">Scan now</button>
<button onclick="toggleSettings()">Settings</button>
<button class="danger" onclick="removeFeed('${f.id}')">Unsubscribe</button>
</div>
<div id="settings" hidden></div>
</div>
<div id="list"></div>`;
const list = $('#list');
if (!entries.length) { list.innerHTML = '<p class="empty">No entries yet — try Scan now.</p>'; return; }
for (const e of entries) list.appendChild(entryEl(f, e));
}
function entryEl(f, e) {
const div = document.createElement('div');
div.className = 'entry' + (e.read ? '' : ' unread');
div.dataset.guid = e.guid;
const isOpen = open.has(e.guid);
div.innerHTML = `
<div class="head">
<h3>${esc(e.title || '(untitled)')}</h3>
<span class="meta">${when(e.published)}</span>
<span title="Keep this episode" style="cursor:pointer">${e.flagged ? '★' : '☆'}</span>
</div>
<div class="body" ${isOpen ? '' : 'hidden'}></div>`;
const head = $('.head', div), body = $('.body', div);
head.querySelector('span[title]').onclick = ev => { ev.stopPropagation(); flag(f, e, !e.flagged); };
head.onclick = () => {
const nowOpen = body.hidden;
body.hidden = !nowOpen;
if (nowOpen) { open.add(e.guid); fillBody(body, f, e); } else { open.delete(e.guid); }
};
if (isOpen) fillBody(body, f, e);
return div;
}
function fillBody(body, f, e) {
// description is sanitized server-side with ammonia before it ever gets here
const encs = e.enclosures.map(x => encEl(f, e, x)).join('');
body.innerHTML = `<div class="desc">${e.description || '<em>No show notes.</em>'}</div>
${encs}
<div class="row">
<button onclick="markRead('${f.id}', ${JSON.stringify(e.guid).replace(/"/g, '&quot;')}, ${!e.read})">
Mark ${e.read ? 'unread' : 'read'}</button>
${e.link ? `<a class="meta" href="${esc(e.link)}" target="_blank" rel="noreferrer noopener">Open original</a>` : ''}
</div>`;
for (const x of e.enclosures) {
const audio = $(`#audio-${x.id}`, body);
// Playing something is the clearest signal it has been listened to, and retention
// deletes read episodes before unread ones.
if (audio) audio.onplay = () => { if (!e.read) markRead(f.id, e.guid, true, true); };
}
}
function encEl(f, e, x) {
const g = JSON.stringify(e.guid).replace(/"/g, '&quot;');
if (x.path) {
return `<div>
<audio id="audio-${x.id}" controls preload="none" src="/media/${x.id}"></audio>
<div class="row">
<span class="state done">downloaded</span><span class="meta">${mb(x.length)}</span>
<a class="meta" href="/media/${x.id}" download>Save file</a>
<button class="danger" onclick="delFile(${x.id})">Delete file</button>
</div></div>`;
}
return `<div>
<div class="row">
<span class="state ${esc(x.state)}">${esc(x.state)}</span>
<span class="meta">${mb(x.length)}</span>
<button onclick="dl(${x.id})">Download</button>
${x.last_error ? `<span class="err">${esc(x.last_error)}</span>` : ''}
</div>
<div class="bar" id="bar-${x.id}"><i></i></div>
</div>`;
}
async function markRead(feedId, guid, read, quiet) {
await api(`/api/entries/${encodeURIComponent(feedId)}/${encodeURIComponent(guid)}/flags`,
{ method: 'POST', body: JSON.stringify({ read }) });
const e = entries.find(x => x.guid === guid); if (e) e.read = read;
if (!quiet) { const f = feeds.find(x => x.id === current); render(f); }
loadFeeds();
}
async function flag(f, e, on) {
await api(`/api/entries/${encodeURIComponent(f.id)}/${encodeURIComponent(e.guid)}/flags`,
{ method: 'POST', body: JSON.stringify({ flagged: on }) });
e.flagged = on; render(f);
}
async function dl(id) { say('Queued…'); await api(`/api/enclosures/${id}/download`, { method: 'POST' }); }
async function delFile(id) {
if (!confirm('Delete this file? The episode stays listed and will not be re-downloaded.')) return;
await api(`/api/enclosures/${id}`, { method: 'DELETE' });
loadEntries(); loadFeeds();
}
async function scan(id) { say('Scanning…'); await api('/api/fetch', { method: 'POST', body: JSON.stringify({ feed: id, force: true }) }); }
async function removeFeed(id) {
if (!confirm(`Unsubscribe from ${id}? Downloads and history are kept.`)) return;
await api(`/api/feeds/${encodeURIComponent(id)}`, { method: 'DELETE' });
current = null; $('#content').innerHTML = '<p class="empty">Pick a feed.</p>'; loadFeeds();
}
function toggleSettings() {
const box = $('#settings'), f = feeds.find(x => x.id === current);
box.hidden = !box.hidden;
if (box.hidden) return;
box.innerHTML = `<form class="settings" onsubmit="return saveSettings(event)">
<label>Folder<input type="text" name="folder" value="${esc(f.folder || '')}" placeholder="${esc(f.title || f.id)}"></label>
<label>Keywords (comma separated; leave empty to take everything)
<input type="text" name="keywords" value="${esc(f.keywords.join(', '))}"></label>
<label>Max new downloads per scan (blank = no limit)
<input type="number" name="max" min="0" value="${f.max_new_per_check ?? ''}"></label>
<div class="checks">
<label><input type="checkbox" name="explicit" ${f.allow_explicit ? 'checked' : ''}> Allow explicit</label>
<label><input type="checkbox" name="auto" ${f.auto_download ? 'checked' : ''}> Auto download</label>
</div>
<div class="row"><button class="primary" type="submit">Save</button></div>
</form>`;
}
async function saveSettings(ev) {
ev.preventDefault();
const d = new FormData(ev.target);
const max = d.get('max');
await api(`/api/feeds/${encodeURIComponent(current)}`, {
method: 'PATCH',
body: JSON.stringify({
folder: d.get('folder').trim() || null,
keywords: d.get('keywords').split(',').map(s => s.trim()).filter(Boolean),
max_new_per_check: max === '' ? null : Number(max),
allow_explicit: d.get('explicit') === 'on',
auto_download: d.get('auto') === 'on',
}),
});
say('Saved — applies to the next scan, no restart needed.');
await loadFeeds();
const f = feeds.find(x => x.id === current);
render(f);
return false;
}
$('#fetchAll').onclick = () => { say('Scanning all feeds…'); api('/api/fetch', { method: 'POST', body: JSON.stringify({ force: true }) }); };
$('#showAdd').onclick = async () => {
const url = prompt('Feed URL');
if (!url) return;
say('Adding…');
try { const r = await api('/api/feeds', { method: 'POST', body: JSON.stringify({ url }) });
say(r.existing ? `Already subscribed as ${r.id}` : `Added ${r.id}`);
await loadFeeds(); selectFeed(r.id);
} catch (e) { say('Failed: ' + e.message); }
};
// Live events from the same broadcast bus the socket clients read.
const sse = new EventSource('/api/events');
sse.onmessage = m => {
const ev = JSON.parse(m.data);
if (ev.ev === 'progress') {
say(`${ev.file}: ${((ev.done / (ev.total || ev.done)) * 100).toFixed(0)}%`);
const bars = document.querySelectorAll('.bar i');
for (const b of bars) if (b.parentElement.id) b.style.width = ((ev.done / (ev.total || 1)) * 100) + '%';
} else if (ev.ev === 'download_done') {
say('Downloaded ' + ev.path.split('/').pop());
loadEntries(); loadFeeds();
} else if (ev.ev === 'feed_done') {
say(`${ev.feed}: ${ev.new} new, ${ev.downloaded} downloaded`);
loadEntries(); loadFeeds();
} else if (ev.ev === 'scan_done') {
say('Scan complete.'); loadFeeds();
} else if (ev.ev === 'feed_error' || ev.ev === 'download_error') {
say('Error: ' + ev.msg);
}
};
loadFeeds();
</script>
</body>
</html>