Accounts and sign-in, and fix the read toggle
epAction's redraw closure called itself when handed a row, so Mark read recursed until the stack blew; it now swaps that row in place. Opening an item also marks it read, redrawn where it stands so nothing vanishes from under the pointer on the Unread tab. Step A of multi-user: users and sessions tables, Argon2id, a session cookie, ipx user subcommands, and a trusted proxy header for Cloudflare Zero Trust -- honoured only from a trusted_proxies address. The shared token still works and is the admin. A new database starts with admin/ipodderx. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01AdXho5tTkjFLeUXKbEjKBh
This commit is contained in:
102
Cargo.lock
generated
102
Cargo.lock
generated
@@ -109,6 +109,18 @@ dependencies = [
|
||||
"rustversion",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "argon2"
|
||||
version = "0.6.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "134c52ddac6d63c576bef8168db10c83c49c26444ecbc68060fef078925a901c"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "arrayvec"
|
||||
version = "0.7.8"
|
||||
@@ -326,6 +338,12 @@ version = "0.23.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
|
||||
|
||||
[[package]]
|
||||
name = "base64ct"
|
||||
version = "1.8.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
|
||||
|
||||
[[package]]
|
||||
name = "bitflags"
|
||||
version = "1.3.2"
|
||||
@@ -350,6 +368,24 @@ dependencies = [
|
||||
"wyz",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "blake2"
|
||||
version = "0.11.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5b5d4d889834ee8ecfc0f8426ad30faf7cdcb10f741a8e6d7224d95325479f6f"
|
||||
dependencies = [
|
||||
"digest",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "block-buffer"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d2f6c7dbe95a6ed67ad9f18e57daf93a2f034c524b99fd2b76d18fdfeb6660aa"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "bs58"
|
||||
version = "0.5.1"
|
||||
@@ -508,6 +544,12 @@ dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cmov"
|
||||
version = "0.5.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
|
||||
|
||||
[[package]]
|
||||
name = "colorchoice"
|
||||
version = "1.0.5"
|
||||
@@ -606,6 +648,15 @@ version = "0.8.23"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6"
|
||||
|
||||
[[package]]
|
||||
name = "crypto-common"
|
||||
version = "0.2.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ce6e4c961d6cd6c9a86db418387425e8bdeaf05b3c8bc1411e6dca4c252f1453"
|
||||
dependencies = [
|
||||
"hybrid-array",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cssparser"
|
||||
version = "0.37.0"
|
||||
@@ -617,6 +668,15 @@ dependencies = [
|
||||
"smallvec",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "ctutils"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
|
||||
dependencies = [
|
||||
"cmov",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "darling"
|
||||
version = "0.20.11"
|
||||
@@ -778,6 +838,17 @@ dependencies = [
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "digest"
|
||||
version = "0.11.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1dd6dbb5841937940781866fa1281a1ff7bd3bf827091440879f9994983d5c2"
|
||||
dependencies = [
|
||||
"block-buffer",
|
||||
"crypto-common",
|
||||
"ctutils",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "diligent-date-parser"
|
||||
version = "0.1.5"
|
||||
@@ -1309,6 +1380,15 @@ version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9"
|
||||
|
||||
[[package]]
|
||||
name = "hybrid-array"
|
||||
version = "0.4.15"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "27f864f10dfb56725ce5ce5472bc52252c8f93a4ab86327122cebf62c5f59a17"
|
||||
dependencies = [
|
||||
"typenum",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "hyper"
|
||||
version = "1.11.1"
|
||||
@@ -1558,6 +1638,7 @@ version = "0.1.0"
|
||||
dependencies = [
|
||||
"ammonia",
|
||||
"anyhow",
|
||||
"argon2",
|
||||
"atom_syndication",
|
||||
"axum",
|
||||
"chrono",
|
||||
@@ -2401,12 +2482,33 @@ dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "password-hash"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "aab41826031698d6ffcd9cff78ef56ef998e39dc7e5067cdfebe373842d4723b"
|
||||
dependencies = [
|
||||
"getrandom 0.4.3",
|
||||
"phc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "percent-encoding"
|
||||
version = "2.3.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220"
|
||||
|
||||
[[package]]
|
||||
name = "phc"
|
||||
version = "0.6.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "44dc769b75f93afdddd8c7fa12d685292ddeff1e66f7f0f3a234cf1818afe892"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"ctutils",
|
||||
"getrandom 0.4.3",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.13.1"
|
||||
|
||||
@@ -6,6 +6,7 @@ edition = "2024"
|
||||
[dependencies]
|
||||
ammonia = "4.1.4"
|
||||
anyhow = "1.0.104"
|
||||
argon2 = "0.6.0"
|
||||
atom_syndication = "0.12.10"
|
||||
axum = "0.8.9"
|
||||
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }
|
||||
|
||||
46
PROGRESS.md
46
PROGRESS.md
@@ -56,19 +56,55 @@ and until now nothing set them.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 — Marking an item read
|
||||
|
||||
Two bugs in one place. `epAction`'s `redraw` closure called *itself* when it had a row to
|
||||
update -- `if(el) redraw()` where it meant to swap the row -- so the Mark read button in the
|
||||
text pane recursed until the stack blew. It now swaps that one row in place and refreshes the
|
||||
text below only when it is the item being read.
|
||||
|
||||
And opening an item now marks it read, which is what clicking a thing to read it means. The row
|
||||
is redrawn where it stands rather than the list reloaded, so an item does not vanish from under
|
||||
the pointer on the Unread tab.
|
||||
|
||||
---
|
||||
|
||||
## 2026-09-10 — Step A: accounts and sign-in
|
||||
|
||||
`users` and `sessions` tables, Argon2id hashing, a session cookie, and `ipx user add|list|passwd|rm`
|
||||
(passwords come in on stdin, so they miss the shell history and any `ps` listing).
|
||||
|
||||
Three ways in, in order of how specific the claim is:
|
||||
|
||||
1. **A proxy header** naming the user -- `Cf-Access-Authenticated-User-Email` for the Cloudflare
|
||||
Zero Trust in front of `ipodderx.sdf1.net`. Honoured **only** from an address in
|
||||
`trusted_proxies` (loopback by default): a header is worth exactly as much as the hop that set
|
||||
it, and the LAN port would otherwise let anyone claim to be anyone. Verified both ways -- a
|
||||
spoof from an untrusted address is refused.
|
||||
2. **A session cookie** from signing in at `/login`.
|
||||
3. **The shared token**, which is the admin, so the healthcheck and existing links keep working.
|
||||
|
||||
A database with no accounts creates **admin / ipodderx** and says so loudly in the log. The UI shows
|
||||
who is signed in above the sidebar footer, with a sign-out, and a 401 sends the page to `/login`.
|
||||
|
||||
Nothing is per-user *yet*: everyone still sees the same feeds and read state. That is step B.
|
||||
|
||||
---
|
||||
|
||||
## Multi-user — the plan
|
||||
|
||||
Decided with Ray: **stay on SQLite** (Postgres was considered and dropped -- it is a deployment
|
||||
choice, not a capability one, and nothing here contends for writes). Sign-in is either a local
|
||||
username and password or the Authentik that already fronts `ipodderx.sdf1.net` through a Cloudflare
|
||||
tunnel. Feeds, items and files are **shared**; read state and subscriptions are **per user**.
|
||||
username and password or the Cloudflare Zero Trust that already fronts `ipodderx.sdf1.net`, which
|
||||
puts the authenticated identity in `Cf-Access-Authenticated-User-Email`. Feeds, items and files are **shared**; read state and subscriptions are **per user**.
|
||||
|
||||
The point of sharing: two people subscribed to the same show cost one fetch, one parse, and one file
|
||||
on disk. `enclosures.url` is already globally UNIQUE, so the file half is nearly free.
|
||||
|
||||
- [ ] **A. Users, sessions, sign-in.** `users` + `sessions` tables, Argon2 hashing, session cookie,
|
||||
`ipx user add|list|passwd|rm`. Authentik/proxy header (`trusted_header` in `[web]`) signs in and
|
||||
optionally creates a user. The existing shared token keeps working and resolves to the admin, so
|
||||
- [x] **A. Users, sessions, sign-in.** `users` + `sessions` tables, Argon2 hashing, session cookie,
|
||||
`ipx user add|list|passwd|rm`. A proxy header (`trusted_header` in `[web]`) signs in and
|
||||
optionally creates a user -- honoured only from a `trusted_proxies` address, so a LAN client
|
||||
cannot simply assert it. The existing shared token keeps working and resolves to the admin, so
|
||||
the healthcheck and any scripts survive. Login page for direct access.
|
||||
- [ ] **B. Per-user read state.** `entry_state(user_id, feed_id, guid, read, flagged, position)`;
|
||||
the current columns on `entries` migrate into the first user's rows. Unread counts, filters and
|
||||
|
||||
@@ -1,28 +1,22 @@
|
||||
services:
|
||||
iPodderX:
|
||||
build: .
|
||||
# image: ipx:latest # swap build: for image: once you have published one
|
||||
ipodderx:
|
||||
image: 192.168.1.130:5000/ipodderx:latest
|
||||
container_name: iPodderX
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Unraid shares expect these; downloads land owned by nobody:users.
|
||||
PUID: "99"
|
||||
PGID: "100"
|
||||
TZ: "America/Toronto"
|
||||
# ipx=debug for verbose, or add librqbit=info to watch torrents.
|
||||
IPX_LOG: "ipx=info"
|
||||
ports:
|
||||
- "8099:8099" # web UI
|
||||
- "6881:6881/tcp" # BitTorrent peers
|
||||
- "6881:6881/udp" # DHT
|
||||
volumes:
|
||||
- ./config:/config # config.toml, and the web token
|
||||
- ./data:/data # state.db
|
||||
- /mnt/user/audio/ipx:/downloads
|
||||
- /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
|
||||
- /mnt/user/ipodderx/:/data # state.db
|
||||
- /mnt/user/ipodderx/downloads:/downloads
|
||||
healthcheck:
|
||||
# `ipx status` proxies through the control socket to the command worker, so this
|
||||
# catches a daemon that is alive but wedged -- not just one that has died. (A
|
||||
# blocked worker is a real failure mode: a torrent used to be able to cause it.)
|
||||
test: ["CMD", "ipx", "status"]
|
||||
interval: 30s
|
||||
timeout: 5s
|
||||
|
||||
92
src/auth.rs
Normal file
92
src/auth.rs
Normal file
@@ -0,0 +1,92 @@
|
||||
//! Who is asking. Sign-in is either a local password or a header set by whatever fronts
|
||||
//! this -- Cloudflare Zero Trust on `ipodderx.sdf1.net`, which puts the authenticated
|
||||
//! address in `Cf-Access-Authenticated-User-Email`.
|
||||
|
||||
use anyhow::{Result, bail};
|
||||
use argon2::Argon2;
|
||||
use argon2::password_hash::{PasswordHasher, PasswordVerifier, phc::PasswordHash};
|
||||
|
||||
/// Argon2id with the crate's defaults, which are the OWASP-recommended parameters. The
|
||||
/// salt is generated per password by the hasher itself.
|
||||
pub fn hash_password(password: &str) -> Result<String> {
|
||||
if password.len() < 8 {
|
||||
bail!("password must be at least 8 characters");
|
||||
}
|
||||
Argon2::default()
|
||||
.hash_password(password.as_bytes())
|
||||
.map(|h| h.to_string())
|
||||
.map_err(|e| anyhow::anyhow!("could not hash the password: {e}"))
|
||||
}
|
||||
|
||||
/// False for a wrong password *and* for a stored hash this build cannot parse; either way
|
||||
/// the answer is no.
|
||||
pub fn verify_password(password: &str, stored: &str) -> bool {
|
||||
let Ok(parsed) = PasswordHash::new(stored) else {
|
||||
tracing::warn!("stored password hash is unreadable; refusing the sign-in");
|
||||
return false;
|
||||
};
|
||||
Argon2::default()
|
||||
.verify_password(password.as_bytes(), &parsed)
|
||||
.is_ok()
|
||||
}
|
||||
|
||||
/// A session id: 256 bits of urandom, hex. Long enough that guessing is not a strategy.
|
||||
pub fn new_session_token() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
if getrandom(&mut bytes).is_err() {
|
||||
// Falling back to the clock would be a predictable session id. Better to fail.
|
||||
panic!("no source of randomness for a session token");
|
||||
}
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
fn getrandom(buf: &mut [u8]) -> std::io::Result<()> {
|
||||
use std::io::Read;
|
||||
std::fs::File::open("/dev/urandom")?.read_exact(buf)
|
||||
}
|
||||
|
||||
/// A username taken from a proxy header. Cloudflare sends an email address; the local part
|
||||
/// is what a person recognises, and the whole thing stays unique enough for one household.
|
||||
pub fn name_from_header(raw: &str) -> Option<String> {
|
||||
let name = raw.trim();
|
||||
if name.is_empty() || name.len() > 190 {
|
||||
return None;
|
||||
}
|
||||
// Anything that could confuse a lookup or a log line is not a name.
|
||||
if name.chars().any(|c| c.is_control() || c == ',' || c == ';') {
|
||||
return None;
|
||||
}
|
||||
Some(name.to_ascii_lowercase())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_password_verifies_only_against_itself() {
|
||||
let h = hash_password("correct horse battery").unwrap();
|
||||
assert!(verify_password("correct horse battery", &h));
|
||||
assert!(!verify_password("Correct horse battery", &h));
|
||||
assert!(!verify_password("", &h));
|
||||
// A hash from a different scheme, or a truncated one, must not authenticate.
|
||||
assert!(!verify_password("correct horse battery", "not-a-hash"));
|
||||
assert!(hash_password("short").is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn session_tokens_are_long_and_distinct() {
|
||||
let a = new_session_token();
|
||||
let b = new_session_token();
|
||||
assert_eq!(a.len(), 64);
|
||||
assert_ne!(a, b);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_header_name_is_cleaned_or_refused() {
|
||||
assert_eq!(name_from_header(" Ray@Example.COM "), Some("ray@example.com".into()));
|
||||
assert_eq!(name_from_header(""), None);
|
||||
assert_eq!(name_from_header("ray\nadmin"), None);
|
||||
assert_eq!(name_from_header("ray;admin"), None);
|
||||
}
|
||||
}
|
||||
@@ -71,8 +71,20 @@ 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.
|
||||
/// Shared secret. Generated and written back on first run when left empty. It signs
|
||||
/// in as the admin, which is what keeps the healthcheck and any scripts working.
|
||||
pub token: String,
|
||||
/// A header naming the signed-in user, set by whatever fronts this -- Cloudflare Zero
|
||||
/// Trust sends `Cf-Access-Authenticated-User-Email`. Empty disables the whole path.
|
||||
pub trusted_header: String,
|
||||
/// Addresses allowed to assert that header. A header is only as trustworthy as the
|
||||
/// hop that set it, so an empty list means nobody: on a LAN-bound port anyone could
|
||||
/// otherwise claim to be anyone. Loopback covers a tunnel running beside the daemon.
|
||||
pub trusted_proxies: Vec<String>,
|
||||
/// Create an account the first time the proxy vouches for a name it has not seen.
|
||||
pub auto_create_users: bool,
|
||||
/// Sign a session out after this long without a request.
|
||||
pub session_days: i64,
|
||||
}
|
||||
|
||||
impl Default for Web {
|
||||
@@ -81,6 +93,10 @@ impl Default for Web {
|
||||
enabled: false,
|
||||
bind: "127.0.0.1:8080".into(),
|
||||
token: String::new(),
|
||||
trusted_header: String::new(),
|
||||
trusted_proxies: vec!["127.0.0.1".into(), "::1".into()],
|
||||
auto_create_users: true,
|
||||
session_days: 30,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
146
src/db.rs
146
src/db.rs
@@ -1,7 +1,7 @@
|
||||
//! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat.
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use rusqlite::{Connection, OptionalExtension};
|
||||
use rusqlite::{Connection, OptionalExtension, params};
|
||||
use std::path::Path;
|
||||
use std::sync::Mutex;
|
||||
|
||||
@@ -69,8 +69,35 @@ CREATE TABLE IF NOT EXISTS enclosures (
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS enclosures_entry ON enclosures (feed_id, guid);
|
||||
|
||||
-- pass_hash is NULL for someone who only ever arrives through the proxy: there is no
|
||||
-- password to check, and leaving it empty is not the same as leaving it unset.
|
||||
CREATE TABLE IF NOT EXISTS users (
|
||||
id INTEGER PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE COLLATE NOCASE,
|
||||
pass_hash TEXT,
|
||||
is_admin INTEGER NOT NULL DEFAULT 0,
|
||||
created INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
created INTEGER NOT NULL,
|
||||
seen INTEGER NOT NULL
|
||||
);
|
||||
";
|
||||
|
||||
/// Someone who can sign in. `pass_hash` is None for an account that only ever arrives
|
||||
/// through the proxy.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct User {
|
||||
pub id: i64,
|
||||
pub name: String,
|
||||
pub pass_hash: Option<String>,
|
||||
pub is_admin: bool,
|
||||
}
|
||||
|
||||
/// A feed derived from an OPML subscription rather than written into the config.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Managed {
|
||||
@@ -681,6 +708,123 @@ impl Db {
|
||||
}
|
||||
|
||||
/// Marks every entry in a feed read, for the "mark all read" button.
|
||||
// ---- users and sessions ----
|
||||
|
||||
pub fn create_user(&self, name: &str, pass_hash: Option<&str>, admin: bool) -> Result<i64> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO users (name, pass_hash, is_admin, created) VALUES (?1, ?2, ?3, ?4)",
|
||||
params![name, pass_hash, admin as i64, now()],
|
||||
)?;
|
||||
Ok(conn.last_insert_rowid())
|
||||
}
|
||||
|
||||
pub fn user_by_name(&self, name: &str) -> Result<Option<User>> {
|
||||
self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE name = ?1", name)
|
||||
}
|
||||
|
||||
pub fn user_by_id(&self, id: i64) -> Result<Option<User>> {
|
||||
self.one_user("SELECT id, name, pass_hash, is_admin FROM users WHERE id = ?1", id)
|
||||
}
|
||||
|
||||
fn one_user<P: rusqlite::ToSql>(&self, sql: &str, key: P) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt = conn.prepare(sql)?;
|
||||
let mut rows = stmt.query(params![key])?;
|
||||
Ok(match rows.next()? {
|
||||
Some(r) => Some(User {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
pass_hash: r.get(2)?,
|
||||
is_admin: r.get::<_, i64>(3)? != 0,
|
||||
}),
|
||||
None => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn users(&self) -> Result<Vec<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let mut stmt =
|
||||
conn.prepare("SELECT id, name, pass_hash, is_admin FROM users ORDER BY name")?;
|
||||
let out = stmt
|
||||
.query_map([], |r| {
|
||||
Ok(User {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
pass_hash: r.get(2)?,
|
||||
is_admin: r.get::<_, i64>(3)? != 0,
|
||||
})
|
||||
})?
|
||||
.collect::<rusqlite::Result<Vec<_>>>()?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
pub fn set_password(&self, id: i64, hash: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("UPDATE users SET pass_hash = ?2 WHERE id = ?1", params![id, hash])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn set_admin(&self, id: i64, admin: bool) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("UPDATE users SET is_admin = ?2 WHERE id = ?1", params![id, admin as i64])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Sessions go with the user: a deleted account must not leave a usable cookie behind.
|
||||
pub fn delete_user(&self, id: i64) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM sessions WHERE user_id = ?1", [id])?;
|
||||
conn.execute("DELETE FROM users WHERE id = ?1", [id])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn create_session(&self, user_id: i64, token: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute(
|
||||
"INSERT INTO sessions (token, user_id, created, seen) VALUES (?1, ?2, ?3, ?3)",
|
||||
params![token, user_id, now()],
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The user behind a session cookie, if it is still live. Idle sessions expire after
|
||||
/// `max_idle_secs`; touching `seen` is what keeps a session in daily use alive.
|
||||
pub fn session_user(&self, token: &str, max_idle_secs: i64) -> Result<Option<User>> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
let cutoff = now() - max_idle_secs;
|
||||
let mut stmt = conn.prepare(
|
||||
"SELECT u.id, u.name, u.pass_hash, u.is_admin
|
||||
FROM sessions s JOIN users u ON u.id = s.user_id
|
||||
WHERE s.token = ?1 AND s.seen >= ?2",
|
||||
)?;
|
||||
let mut rows = stmt.query(params![token, cutoff])?;
|
||||
let found = match rows.next()? {
|
||||
Some(r) => Some(User {
|
||||
id: r.get(0)?,
|
||||
name: r.get(1)?,
|
||||
pass_hash: r.get(2)?,
|
||||
is_admin: r.get::<_, i64>(3)? != 0,
|
||||
}),
|
||||
None => None,
|
||||
};
|
||||
drop(rows);
|
||||
drop(stmt);
|
||||
if found.is_some() {
|
||||
conn.execute("UPDATE sessions SET seen = ?2 WHERE token = ?1", params![token, now()])?;
|
||||
} else {
|
||||
// Either unknown or timed out; either way it is dead weight.
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?1 OR seen < ?2", params![token, cutoff])?;
|
||||
}
|
||||
Ok(found)
|
||||
}
|
||||
|
||||
pub fn delete_session(&self, token: &str) -> Result<()> {
|
||||
let conn = self.conn.lock().unwrap();
|
||||
conn.execute("DELETE FROM sessions WHERE token = ?1", [token])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Marks every entry of the given feeds read. Takes a list because an OPML subscription
|
||||
/// holds no entries itself -- marking it read means the feeds inside it.
|
||||
pub fn mark_all_read(&self, feed_ids: &[String]) -> Result<usize> {
|
||||
|
||||
118
src/main.rs
118
src/main.rs
@@ -1,3 +1,4 @@
|
||||
mod auth;
|
||||
mod config;
|
||||
mod db;
|
||||
mod download;
|
||||
@@ -67,6 +68,11 @@ enum Command {
|
||||
Import { file: PathBuf },
|
||||
/// Write subscriptions out as OPML
|
||||
Export { file: PathBuf },
|
||||
/// Add, list or remove the accounts that can sign in to the web UI
|
||||
User {
|
||||
#[command(subcommand)]
|
||||
cmd: UserCmd,
|
||||
},
|
||||
/// Run the scheduler and serve the control socket
|
||||
Daemon {
|
||||
/// Serve the web UI on this address, overriding [web] in the config
|
||||
@@ -75,6 +81,30 @@ enum Command {
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum UserCmd {
|
||||
/// Create an account. The password is read from stdin: `echo -n hunter2 | ipx user add ray`
|
||||
Add {
|
||||
name: String,
|
||||
/// May manage other accounts, and is who the shared web token signs in as
|
||||
#[arg(long)]
|
||||
admin: bool,
|
||||
/// Sign-in comes from the proxy instead, so there is no password to set
|
||||
#[arg(long)]
|
||||
no_password: bool,
|
||||
},
|
||||
/// Show the accounts and how each one signs in
|
||||
List,
|
||||
/// Replace a password, read from stdin
|
||||
Passwd { name: String },
|
||||
/// Delete an account and everything it knows: its subscriptions and read state
|
||||
Rm { name: String },
|
||||
}
|
||||
|
||||
/// What a brand new database starts with, so there is always a way in. Announced loudly
|
||||
/// in the log, and the first thing the settings page nags about.
|
||||
const DEFAULT_PASSWORD: &str = "ipodderx";
|
||||
|
||||
/// Everything a command needs. One per process.
|
||||
pub struct Ctx {
|
||||
/// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks
|
||||
@@ -157,6 +187,7 @@ async fn main() -> Result<()> {
|
||||
Command::Status => Some(Cmd::Status),
|
||||
Command::List
|
||||
| Command::Daemon { .. }
|
||||
| Command::User { .. }
|
||||
| Command::Add { .. }
|
||||
| Command::Rm { .. }
|
||||
| Command::Import { .. }
|
||||
@@ -191,12 +222,89 @@ async fn main() -> Result<()> {
|
||||
add(&ctx, &config_path, &url, folder, keywords).await
|
||||
}
|
||||
Command::Rm { feed } => rm(&ctx, &config_path, &feed),
|
||||
Command::User { cmd } => user_cmd(&ctx, cmd),
|
||||
Command::Import { file } => import(&ctx, &config_path, &file).await,
|
||||
Command::Export { file } => export(&ctx, &file),
|
||||
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Accounts. Passwords come in on stdin so they never reach a shell history or a `ps`
|
||||
/// listing.
|
||||
fn user_cmd(ctx: &Arc<Ctx>, cmd: UserCmd) -> Result<()> {
|
||||
let read_password = || -> Result<String> {
|
||||
use std::io::Read;
|
||||
let mut buf = String::new();
|
||||
std::io::stdin().read_to_string(&mut buf)?;
|
||||
let pw = buf.trim_end_matches(['\n', '\r']).to_string();
|
||||
if pw.is_empty() {
|
||||
anyhow::bail!("no password on stdin: try `echo -n secret | ipx user ...`");
|
||||
}
|
||||
Ok(pw)
|
||||
};
|
||||
|
||||
match cmd {
|
||||
UserCmd::Add { name, admin, no_password } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
if name.is_empty() {
|
||||
anyhow::bail!("a name is required");
|
||||
}
|
||||
if ctx.db.user_by_name(&name)?.is_some() {
|
||||
anyhow::bail!("{name} already exists");
|
||||
}
|
||||
let hash = if no_password {
|
||||
None
|
||||
} else {
|
||||
Some(crate::auth::hash_password(&read_password()?)?)
|
||||
};
|
||||
// The first account runs the place; there is nobody else to grant it.
|
||||
let first = ctx.db.users()?.is_empty();
|
||||
ctx.db.create_user(&name, hash.as_deref(), admin || first)?;
|
||||
println!(
|
||||
"added {name}{}{}",
|
||||
if admin || first { " (admin)" } else { "" },
|
||||
if no_password { ", signs in through the proxy" } else { "" }
|
||||
);
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::List => {
|
||||
let users = ctx.db.users()?;
|
||||
if users.is_empty() {
|
||||
println!("no accounts yet: ipx user add <name>");
|
||||
}
|
||||
for u in users {
|
||||
println!(
|
||||
"{:<20} {:<8} {}",
|
||||
u.name,
|
||||
if u.is_admin { "admin" } else { "" },
|
||||
if u.pass_hash.is_some() { "password" } else { "proxy only" }
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Passwd { name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let user = ctx
|
||||
.db
|
||||
.user_by_name(&name)?
|
||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
ctx.db.set_password(user.id, &crate::auth::hash_password(&read_password()?)?)?;
|
||||
println!("password changed for {name}");
|
||||
Ok(())
|
||||
}
|
||||
UserCmd::Rm { name } => {
|
||||
let name = name.trim().to_ascii_lowercase();
|
||||
let user = ctx
|
||||
.db
|
||||
.user_by_name(&name)?
|
||||
.ok_or_else(|| anyhow::anyhow!("no such account: {name}"))?;
|
||||
ctx.db.delete_user(user.id)?;
|
||||
println!("removed {name}");
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
|
||||
match cmd {
|
||||
Cmd::Fetch { feed, force } => {
|
||||
@@ -226,6 +334,16 @@ async fn daemon(
|
||||
anyhow::bail!("a daemon is already listening on {}", socket.display());
|
||||
}
|
||||
|
||||
// A database with nobody in it cannot be signed into, and an install that predates
|
||||
// accounts still has to serve its owner. Both get the same starting point.
|
||||
if ctx.db.users()?.is_empty() {
|
||||
ctx.db.create_user("admin", Some(&crate::auth::hash_password(DEFAULT_PASSWORD)?), true)?;
|
||||
tracing::warn!(
|
||||
"no accounts yet: created 'admin' with the default password '{DEFAULT_PASSWORD}'. \
|
||||
Change it with `echo -n <password> | ipx user passwd admin`"
|
||||
);
|
||||
}
|
||||
|
||||
match migrate_opml_children(&ctx) {
|
||||
Ok(n) if n > 0 => tracing::info!(count = n, "moved OPML feeds out of config.toml into the database"),
|
||||
Ok(_) => {}
|
||||
|
||||
231
src/web.rs
231
src/web.rs
@@ -7,7 +7,7 @@ use axum::{
|
||||
http::{StatusCode, header},
|
||||
middleware::{self, Next},
|
||||
response::{
|
||||
Html, IntoResponse, Response,
|
||||
Html, IntoResponse, Redirect, Response,
|
||||
sse::{Event as SseEvent, Sse},
|
||||
},
|
||||
routing::{delete, get, patch, post},
|
||||
@@ -60,6 +60,8 @@ pub fn generate_token() -> String {
|
||||
pub fn router(state: WebState) -> Router {
|
||||
Router::new()
|
||||
.route("/", get(index))
|
||||
.route("/api/me", get(me))
|
||||
.route("/api/logout", post(logout))
|
||||
.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))
|
||||
@@ -76,6 +78,9 @@ pub fn router(state: WebState) -> Router {
|
||||
.route("/api/events", get(events))
|
||||
.route("/media/{id}", get(media))
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||
// Signing in cannot require being signed in, so these sit outside the auth layer.
|
||||
.route("/login", get(login_page))
|
||||
.route("/api/login", post(login))
|
||||
.layer(middleware::from_fn(access_log))
|
||||
.with_state(state)
|
||||
}
|
||||
@@ -85,53 +90,223 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> {
|
||||
.await
|
||||
.with_context(|| format!("binding {bind}"))?;
|
||||
tracing::info!(bind, "web ui listening");
|
||||
axum::serve(listener, router(state))
|
||||
axum::serve(
|
||||
listener,
|
||||
router(state).into_make_service_with_connect_info::<std::net::SocketAddr>(),
|
||||
)
|
||||
.await
|
||||
.context("serving the web ui")
|
||||
}
|
||||
|
||||
/// Token in `?token=` (which then sets a cookie) or in the cookie itself.
|
||||
/// Who is asking, in order of how specific the claim is: a proxy that vouches for a name,
|
||||
/// a session cookie, then the shared token (which is the admin).
|
||||
///
|
||||
/// 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();
|
||||
/// Any of them has to survive being put in a cookie: 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>, mut req: Request, next: Next) -> Response {
|
||||
let cfg = state.ctx.cfg();
|
||||
let token = cfg.web.token.clone();
|
||||
|
||||
let peer = req
|
||||
.extensions()
|
||||
.get::<axum::extract::ConnectInfo<std::net::SocketAddr>>()
|
||||
.map(|c| c.0.ip().to_string())
|
||||
.unwrap_or_default();
|
||||
|
||||
// 1. A header, but only from a hop we were told to believe. Anyone able to reach the
|
||||
// port could otherwise send it and be whoever they liked.
|
||||
let vouched = (!cfg.web.trusted_header.is_empty()
|
||||
&& cfg.web.trusted_proxies.iter().any(|p| p == &peer))
|
||||
.then(|| {
|
||||
req.headers()
|
||||
.get(&cfg.web.trusted_header)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.and_then(crate::auth::name_from_header)
|
||||
})
|
||||
.flatten();
|
||||
|
||||
let mut set_cookie: Option<String> = None;
|
||||
let mut user = None;
|
||||
|
||||
if let Some(name) = vouched {
|
||||
user = match state.ctx.db.user_by_name(&name) {
|
||||
Ok(Some(u)) => Some(u),
|
||||
Ok(None) if cfg.web.auto_create_users => {
|
||||
tracing::info!(user = %name, "creating an account for a name the proxy vouched for");
|
||||
state
|
||||
.ctx
|
||||
.db
|
||||
.create_user(&name, None, state.ctx.db.users().map(|u| u.is_empty()).unwrap_or(false))
|
||||
.ok()
|
||||
.and_then(|id| state.ctx.db.user_by_id(id).ok().flatten())
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::warn!(user = %name, "proxy vouched for an unknown name and auto_create_users is off");
|
||||
None
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::error!(error = %e, "looking up the vouched user");
|
||||
None
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
// 2. A session cookie from signing in here.
|
||||
if user.is_none() {
|
||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||
user = state
|
||||
.ctx
|
||||
.db
|
||||
.session_user(&sid, cfg.web.session_days.max(1) * 86_400)
|
||||
.unwrap_or(None);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. The shared token, which is the admin: the healthcheck and any scripts predate
|
||||
// accounts and must keep working.
|
||||
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();
|
||||
if user.is_none() && !token.is_empty() {
|
||||
let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
|
||||
if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
|
||||
user = admin_user(&state);
|
||||
if from_query.is_some() {
|
||||
set_cookie = Some(format!(
|
||||
"{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
|
||||
));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let Some(user) = user else {
|
||||
// A browser asking for a page gets the sign-in form; anything else gets a 401 it
|
||||
// can act on.
|
||||
let wants_html = req
|
||||
.headers()
|
||||
.get(header::ACCEPT)
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.is_some_and(|a| a.contains("text/html"));
|
||||
return if wants_html {
|
||||
Redirect::to("/login").into_response()
|
||||
} else {
|
||||
(StatusCode::UNAUTHORIZED, "sign in").into_response()
|
||||
};
|
||||
};
|
||||
|
||||
req.extensions_mut().insert(user);
|
||||
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"
|
||||
)) {
|
||||
if let Some(c) = set_cookie {
|
||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
||||
resp.headers_mut().insert(header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
/// Compares without leaking length or position through timing.
|
||||
const SESSION_COOKIE: &str = "ipx_session";
|
||||
|
||||
/// Handlers take `User` to say they need one; the auth layer put it there, and nothing
|
||||
/// reaches a handler without passing through it.
|
||||
impl<S: Send + Sync> axum::extract::FromRequestParts<S> for crate::db::User {
|
||||
type Rejection = (StatusCode, &'static str);
|
||||
|
||||
async fn from_request_parts(
|
||||
parts: &mut axum::http::request::Parts,
|
||||
_state: &S,
|
||||
) -> std::result::Result<Self, Self::Rejection> {
|
||||
parts
|
||||
.extensions
|
||||
.get::<crate::db::User>()
|
||||
.cloned()
|
||||
.ok_or((StatusCode::UNAUTHORIZED, "sign in"))
|
||||
}
|
||||
}
|
||||
|
||||
fn cookie(req: &Request, name: &str) -> Option<String> {
|
||||
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!("{name}=")).map(str::to_owned))
|
||||
})
|
||||
}
|
||||
|
||||
/// The account the shared token stands for: the first admin, or the first user at all.
|
||||
fn admin_user(state: &WebState) -> Option<crate::db::User> {
|
||||
let users = state.ctx.db.users().ok()?;
|
||||
users
|
||||
.iter()
|
||||
.find(|u| u.is_admin)
|
||||
.or_else(|| users.first())
|
||||
.cloned()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Credentials {
|
||||
name: String,
|
||||
password: String,
|
||||
}
|
||||
|
||||
async fn login(
|
||||
State(state): State<WebState>,
|
||||
Json(body): Json<Credentials>,
|
||||
) -> Result<Response, ApiError> {
|
||||
let name = body.name.trim().to_ascii_lowercase();
|
||||
let user = state.ctx.db.user_by_name(&name)?;
|
||||
// The same answer either way: whether a name exists is not something to leak.
|
||||
let ok = user
|
||||
.as_ref()
|
||||
.and_then(|u| u.pass_hash.as_deref())
|
||||
.is_some_and(|h| crate::auth::verify_password(&body.password, h));
|
||||
if !ok {
|
||||
tracing::warn!(user = %name, "failed sign-in");
|
||||
return Ok((StatusCode::UNAUTHORIZED, "wrong name or password").into_response());
|
||||
}
|
||||
|
||||
let user = user.expect("verified above");
|
||||
let token = crate::auth::new_session_token();
|
||||
state.ctx.db.create_session(user.id, &token)?;
|
||||
tracing::info!(user = %user.name, "signed in");
|
||||
|
||||
let days = state.ctx.cfg().web.session_days.max(1);
|
||||
let mut resp = Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||
.into_response();
|
||||
if let Ok(v) = header::HeaderValue::from_str(&format!(
|
||||
"{SESSION_COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age={}",
|
||||
days * 86_400
|
||||
)) {
|
||||
resp.headers_mut().insert(header::SET_COOKIE, v);
|
||||
}
|
||||
Ok(resp)
|
||||
}
|
||||
|
||||
async fn logout(State(state): State<WebState>, req: Request) -> Response {
|
||||
if let Some(sid) = cookie(&req, SESSION_COOKIE) {
|
||||
let _ = state.ctx.db.delete_session(&sid);
|
||||
}
|
||||
let mut resp = StatusCode::NO_CONTENT.into_response();
|
||||
for c in [
|
||||
format!("{SESSION_COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
|
||||
format!("{COOKIE}=; Path=/; HttpOnly; SameSite=Lax; Max-Age=0"),
|
||||
] {
|
||||
if let Ok(v) = header::HeaderValue::from_str(&c) {
|
||||
resp.headers_mut().append(header::SET_COOKIE, v);
|
||||
}
|
||||
}
|
||||
resp
|
||||
}
|
||||
|
||||
async fn me(user: crate::db::User) -> Json<serde_json::Value> {
|
||||
Json(serde_json::json!({ "name": user.name, "admin": user.is_admin }))
|
||||
}
|
||||
|
||||
async fn login_page() -> Html<&'static str> {
|
||||
Html(include_str!("../web/login.html"))
|
||||
}
|
||||
|
||||
fn constant_time_eq(a: &str, b: &str) -> bool {
|
||||
let (a, b) = (a.as_bytes(), b.as_bytes());
|
||||
if a.len() != b.len() {
|
||||
|
||||
@@ -234,3 +234,25 @@ test.describe('on a phone', () => {
|
||||
await expect(page.locator('#detail')).not.toBeVisible();
|
||||
});
|
||||
});
|
||||
|
||||
test('opening an item marks it read, and the toggle flips it back', async ({ page }) => {
|
||||
const errors = [];
|
||||
page.on('pageerror', e => errors.push(e.message));
|
||||
|
||||
await page.getByText('Test Show').click();
|
||||
const row = () => page.locator('.ep', { hasText: 'Second Episode' });
|
||||
await expect(row()).toBeVisible({ timeout: 20_000 });
|
||||
|
||||
// Another test may have opened this item already, so start from a known state: the
|
||||
// toggle in the text below flips it back -- which used to recurse until the stack blew.
|
||||
await row().click();
|
||||
await page.locator('#detail button', { hasText: 'Mark unread' }).click();
|
||||
await expect(row()).not.toHaveClass(/read/);
|
||||
await expect(page.locator('#detail button', { hasText: 'Mark read' })).toBeVisible();
|
||||
|
||||
// Opening it is reading it.
|
||||
await row().click();
|
||||
await expect(row()).toHaveClass(/read/);
|
||||
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -80,7 +80,15 @@ a{color:var(--accent)}
|
||||
}
|
||||
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)}
|
||||
/* Settings and the log are housekeeping: they sit under the feeds, out of the way. */
|
||||
.sidefoot{display:flex;gap:6px;padding:8px 12px;border-top:1px solid var(--line);flex:none}
|
||||
.sidefoot{
|
||||
display:flex;flex-direction:column;gap:7px;padding:8px 12px;
|
||||
border-top:1px solid var(--line);flex:none;
|
||||
}
|
||||
.sidefoot .row{display:flex;gap:6px}
|
||||
.who{display:flex;align-items:center;gap:8px;font-size:11.5px;color:var(--faint)}
|
||||
.who span{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
||||
.who button{flex:none;background:none;border:0;padding:2px 4px;color:var(--faint);text-decoration:underline}
|
||||
.who button:hover{color:var(--fg)}
|
||||
.sidefoot button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
|
||||
.sidefoot i{font-style:normal;opacity:.75}
|
||||
.searchwrap{padding:0 12px 8px}
|
||||
@@ -368,9 +376,12 @@ input[type=range]::-moz-range-thumb{width:12px;height:12px;border:0;border-radiu
|
||||
<div class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
|
||||
<div id="feedlist"></div>
|
||||
<div class="sidefoot">
|
||||
<div class="who"><span id="who"></span><button id="signout" title="Sign out">Sign out</button></div>
|
||||
<div class="row">
|
||||
<button id="prefs" title="Settings"><i>⚙</i>Settings</button>
|
||||
<button id="logs" title="Daemon and web log"><i>▤</i>Log</button>
|
||||
</div>
|
||||
</div>
|
||||
</aside>
|
||||
<div id="main">
|
||||
<div id="mbar">
|
||||
@@ -422,6 +433,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&','<':'<','>':'>
|
||||
|
||||
async function api(url,opts){
|
||||
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...opts});
|
||||
if(r.status===401){ location.href='/login'; throw new Error('signed out'); }
|
||||
if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
|
||||
return r.status===204?null:r.json().catch(()=>null);
|
||||
}
|
||||
@@ -765,9 +777,27 @@ function kindOf(enc){
|
||||
function feedArt(){ const f=S.feeds.find(x=>x.id===S.feed); return f&&f.image; }
|
||||
|
||||
/// Selecting an item shows it in the pane below, rather than expanding the row.
|
||||
/// Replaces one row with a fresh one, leaving the rest of the list and its scroll alone.
|
||||
function swapRow(e){
|
||||
const row=$(`#eps .ep[data-guid="${CSS.escape(e.guid)}"]`);
|
||||
if(row) row.replaceWith(epEl(e));
|
||||
}
|
||||
|
||||
/// Opening an item is reading it. The row is redrawn where it stands rather than the list
|
||||
/// reloaded, so an item does not vanish from under the pointer on the Unread tab.
|
||||
function markRead(e){
|
||||
if(e.read) return;
|
||||
e.read=true;
|
||||
api(`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}/flags`,
|
||||
{method:'POST',body:JSON.stringify({read:true})})
|
||||
.then(()=>loadFeeds(true)).catch(err=>{ e.read=false; toast(err.message,true); });
|
||||
}
|
||||
|
||||
function selectEntry(e){
|
||||
S.sel=e.guid;
|
||||
markRead(e);
|
||||
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
|
||||
swapRow(e);
|
||||
showDetail(e);
|
||||
const d=$('#detail'); if(d) d.scrollTop=0;
|
||||
}
|
||||
@@ -861,8 +891,8 @@ function encBox(x){
|
||||
}
|
||||
|
||||
async function epAction(a,e,el,encId){
|
||||
// From a row we have the element to swap; from the detail pane we do not, so redraw.
|
||||
const redraw=()=>{ if(el) redraw(); else renderEntries(); showDetail(e); };
|
||||
// Swap the row in place and refresh the text below when it is the one being read.
|
||||
const redraw=()=>{ swapRow(e); if(!el || S.sel===e.guid) showDetail(e); };
|
||||
const enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
|
||||
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
|
||||
try{
|
||||
@@ -1304,6 +1334,8 @@ function on(sel,ev,fn){
|
||||
}
|
||||
$('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
|
||||
on('#prefs','onclick',prefsModal);
|
||||
on('#signout','onclick',async()=>{ await api('/api/logout',{method:'POST'}); location.href='/login'; });
|
||||
api('/api/me').then(u=>{ $('#who').textContent=u.name+(u.admin?' · admin':''); }).catch(()=>{});
|
||||
on('#logs','onclick',logsModal);
|
||||
$('#feedFilter').oninput=renderFeeds;
|
||||
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));
|
||||
|
||||
91
web/login.html
Normal file
91
web/login.html
Normal file
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user