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:
2026-09-10 23:11:20 +00:00
parent ed26fa2061
commit 06f182b555
12 changed files with 876 additions and 53 deletions

102
Cargo.lock generated
View File

@@ -109,6 +109,18 @@ dependencies = [
"rustversion", "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]] [[package]]
name = "arrayvec" name = "arrayvec"
version = "0.7.8" version = "0.7.8"
@@ -326,6 +338,12 @@ version = "0.23.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5" checksum = "ac07cdecf99051d9a5238b80f35af32cdeba5b336e55d957b318b50137e18da5"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]] [[package]]
name = "bitflags" name = "bitflags"
version = "1.3.2" version = "1.3.2"
@@ -350,6 +368,24 @@ dependencies = [
"wyz", "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]] [[package]]
name = "bs58" name = "bs58"
version = "0.5.1" version = "0.5.1"
@@ -508,6 +544,12 @@ dependencies = [
"cc", "cc",
] ]
[[package]]
name = "cmov"
version = "0.5.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a"
[[package]] [[package]]
name = "colorchoice" name = "colorchoice"
version = "1.0.5" version = "1.0.5"
@@ -606,6 +648,15 @@ version = "0.8.23"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a31eee39dddec8330830986fcd7625edb5a24ec90ea038215273bbc3adb08ac6" 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]] [[package]]
name = "cssparser" name = "cssparser"
version = "0.37.0" version = "0.37.0"
@@ -617,6 +668,15 @@ dependencies = [
"smallvec", "smallvec",
] ]
[[package]]
name = "ctutils"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7d5515a3834141de9eafb9717ad39eea8247b5674e6066c404e8c4b365d2a29e"
dependencies = [
"cmov",
]
[[package]] [[package]]
name = "darling" name = "darling"
version = "0.20.11" version = "0.20.11"
@@ -778,6 +838,17 @@ dependencies = [
"syn 2.0.119", "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]] [[package]]
name = "diligent-date-parser" name = "diligent-date-parser"
version = "0.1.5" version = "0.1.5"
@@ -1309,6 +1380,15 @@ version = "1.0.3"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df3b46402a9d5adb4c86a0cf463f42e19994e3ee891101b1841f30a545cb49a9" 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]] [[package]]
name = "hyper" name = "hyper"
version = "1.11.1" version = "1.11.1"
@@ -1558,6 +1638,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"ammonia", "ammonia",
"anyhow", "anyhow",
"argon2",
"atom_syndication", "atom_syndication",
"axum", "axum",
"chrono", "chrono",
@@ -2401,12 +2482,33 @@ dependencies = [
"windows-link", "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]] [[package]]
name = "percent-encoding" name = "percent-encoding"
version = "2.3.2" version = "2.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" 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]] [[package]]
name = "phf" name = "phf"
version = "0.13.1" version = "0.13.1"

View File

@@ -6,6 +6,7 @@ edition = "2024"
[dependencies] [dependencies]
ammonia = "4.1.4" ammonia = "4.1.4"
anyhow = "1.0.104" anyhow = "1.0.104"
argon2 = "0.6.0"
atom_syndication = "0.12.10" atom_syndication = "0.12.10"
axum = "0.8.9" axum = "0.8.9"
chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] } chrono = { version = "0.4.45", default-features = false, features = ["std", "clock"] }

View File

@@ -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 ## Multi-user — the plan
Decided with Ray: **stay on SQLite** (Postgres was considered and dropped -- it is a deployment 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 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 username and password or the Cloudflare Zero Trust that already fronts `ipodderx.sdf1.net`, which
tunnel. Feeds, items and files are **shared**; read state and subscriptions are **per user**. 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 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. 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, - [x] **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 `ipx user add|list|passwd|rm`. A 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 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. 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)`; - [ ] **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 the current columns on `entries` migrate into the first user's rows. Unread counts, filters and

View File

@@ -1,28 +1,22 @@
services: services:
iPodderX: ipodderx:
build: . image: 192.168.1.130:5000/ipodderx:latest
# image: ipx:latest # swap build: for image: once you have published one
container_name: iPodderX container_name: iPodderX
restart: unless-stopped restart: unless-stopped
environment: environment:
# Unraid shares expect these; downloads land owned by nobody:users.
PUID: "99" PUID: "99"
PGID: "100" PGID: "100"
TZ: "America/Toronto" TZ: "America/Toronto"
# ipx=debug for verbose, or add librqbit=info to watch torrents.
IPX_LOG: "ipx=info" IPX_LOG: "ipx=info"
ports: ports:
- "8099:8099" # web UI - "8099:8099" # web UI
- "6881:6881/tcp" # BitTorrent peers - "6881:6881/tcp" # BitTorrent peers
- "6881:6881/udp" # DHT - "6881:6881/udp" # DHT
volumes: volumes:
- ./config:/config # config.toml, and the web token - /mnt/fast/appdata/ipodderx:/config # config.toml, and the web token
- ./data:/data # state.db - /mnt/user/ipodderx/:/data # state.db
- /mnt/user/audio/ipx:/downloads - /mnt/user/ipodderx/downloads:/downloads
healthcheck: 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"] test: ["CMD", "ipx", "status"]
interval: 30s interval: 30s
timeout: 5s timeout: 5s

92
src/auth.rs Normal file
View 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);
}
}

View File

@@ -71,8 +71,20 @@ pub struct Web {
pub enabled: bool, pub enabled: bool,
/// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token. /// Use 0.0.0.0 to reach it from the LAN. Anything but loopback needs the token.
pub bind: String, 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, 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 { impl Default for Web {
@@ -81,6 +93,10 @@ impl Default for Web {
enabled: false, enabled: false,
bind: "127.0.0.1:8080".into(), bind: "127.0.0.1:8080".into(),
token: String::new(), 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
View File

@@ -1,7 +1,7 @@
//! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat. //! SQLite state. Replaces the per-feed .ipxd plists, history.dat and qmcache.dat.
use anyhow::{Context, Result}; use anyhow::{Context, Result};
use rusqlite::{Connection, OptionalExtension}; use rusqlite::{Connection, OptionalExtension, params};
use std::path::Path; use std::path::Path;
use std::sync::Mutex; 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); 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. /// A feed derived from an OPML subscription rather than written into the config.
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct Managed { pub struct Managed {
@@ -681,6 +708,123 @@ impl Db {
} }
/// Marks every entry in a feed read, for the "mark all read" button. /// 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 /// 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. /// holds no entries itself -- marking it read means the feeds inside it.
pub fn mark_all_read(&self, feed_ids: &[String]) -> Result<usize> { pub fn mark_all_read(&self, feed_ids: &[String]) -> Result<usize> {

View File

@@ -1,3 +1,4 @@
mod auth;
mod config; mod config;
mod db; mod db;
mod download; mod download;
@@ -67,6 +68,11 @@ enum Command {
Import { file: PathBuf }, Import { file: PathBuf },
/// Write subscriptions out as OPML /// Write subscriptions out as OPML
Export { file: PathBuf }, 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 /// Run the scheduler and serve the control socket
Daemon { Daemon {
/// Serve the web UI on this address, overriding [web] in the config /// 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. /// Everything a command needs. One per process.
pub struct Ctx { pub struct Ctx {
/// Swapped wholesale when the web UI rewrites config.toml, so a running daemon picks /// 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::Status => Some(Cmd::Status),
Command::List Command::List
| Command::Daemon { .. } | Command::Daemon { .. }
| Command::User { .. }
| Command::Add { .. } | Command::Add { .. }
| Command::Rm { .. } | Command::Rm { .. }
| Command::Import { .. } | Command::Import { .. }
@@ -191,12 +222,89 @@ async fn main() -> Result<()> {
add(&ctx, &config_path, &url, folder, keywords).await add(&ctx, &config_path, &url, folder, keywords).await
} }
Command::Rm { feed } => rm(&ctx, &config_path, &feed), 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::Import { file } => import(&ctx, &config_path, &file).await,
Command::Export { file } => export(&ctx, &file), Command::Export { file } => export(&ctx, &file),
_ => run(&ctx, wire_cmd.expect("only List and Daemon have no wire form")).await, _ => 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<()> { async fn run(ctx: &Arc<Ctx>, cmd: Cmd) -> Result<()> {
match cmd { match cmd {
Cmd::Fetch { feed, force } => { Cmd::Fetch { feed, force } => {
@@ -226,6 +334,16 @@ async fn daemon(
anyhow::bail!("a daemon is already listening on {}", socket.display()); 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) { 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(n) if n > 0 => tracing::info!(count = n, "moved OPML feeds out of config.toml into the database"),
Ok(_) => {} Ok(_) => {}

View File

@@ -7,7 +7,7 @@ use axum::{
http::{StatusCode, header}, http::{StatusCode, header},
middleware::{self, Next}, middleware::{self, Next},
response::{ response::{
Html, IntoResponse, Response, Html, IntoResponse, Redirect, Response,
sse::{Event as SseEvent, Sse}, sse::{Event as SseEvent, Sse},
}, },
routing::{delete, get, patch, post}, routing::{delete, get, patch, post},
@@ -60,6 +60,8 @@ pub fn generate_token() -> String {
pub fn router(state: WebState) -> Router { pub fn router(state: WebState) -> Router {
Router::new() Router::new()
.route("/", get(index)) .route("/", get(index))
.route("/api/me", get(me))
.route("/api/logout", post(logout))
.route("/api/feeds", get(feeds).post(add_feed)) .route("/api/feeds", get(feeds).post(add_feed))
.route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed)) .route("/api/feeds/{id}", patch(patch_feed).delete(remove_feed))
.route("/api/feeds/{id}/entries", get(entries)) .route("/api/feeds/{id}/entries", get(entries))
@@ -76,6 +78,9 @@ pub fn router(state: WebState) -> Router {
.route("/api/events", get(events)) .route("/api/events", get(events))
.route("/media/{id}", get(media)) .route("/media/{id}", get(media))
.layer(middleware::from_fn_with_state(state.clone(), auth)) .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)) .layer(middleware::from_fn(access_log))
.with_state(state) .with_state(state)
} }
@@ -85,53 +90,223 @@ pub async fn serve(state: WebState, bind: &str) -> Result<()> {
.await .await
.with_context(|| format!("binding {bind}"))?; .with_context(|| format!("binding {bind}"))?;
tracing::info!(bind, "web ui listening"); 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 .await
.context("serving the web ui") .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 /// Any of them has to survive being put in a cookie: an `<audio src>` request is issued by
/// browser, and there is no way to attach a header to it. /// 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 { async fn auth(State(state): State<WebState>, mut req: Request, next: Next) -> Response {
let expected = state.ctx.cfg().web.token.clone(); let cfg = state.ctx.cfg();
if expected.is_empty() { let token = cfg.web.token.clone();
// Refuse to serve rather than serve unauthenticated.
return (StatusCode::INTERNAL_SERVER_ERROR, "no web token configured").into_response(); 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| { let from_query = req.uri().query().and_then(|q| {
q.split('&') q.split('&')
.find_map(|kv| kv.strip_prefix("token=").map(str::to_owned)) .find_map(|kv| kv.strip_prefix("token=").map(str::to_owned))
}); });
let from_cookie = req if user.is_none() && !token.is_empty() {
.headers() let supplied = from_query.clone().or_else(|| cookie(&req, COOKIE));
.get(header::COOKIE) if supplied.is_some_and(|t| constant_time_eq(&t, &token)) {
.and_then(|v| v.to_str().ok()) user = admin_user(&state);
.and_then(|c| { if from_query.is_some() {
c.split(';') set_cookie = Some(format!(
.find_map(|kv| kv.trim().strip_prefix(&format!("{COOKIE}=")).map(str::to_owned)) "{COOKIE}={token}; Path=/; HttpOnly; SameSite=Lax; Max-Age=31536000"
}); ));
}
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 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; let mut resp = next.run(req).await;
if from_query.is_some() { if let Some(c) = set_cookie {
// Remember it so the rest of the page (and the audio element) authenticates. if let Ok(v) = header::HeaderValue::from_str(&c) {
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.headers_mut().insert(header::SET_COOKIE, v);
} }
} }
resp 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 { fn constant_time_eq(a: &str, b: &str) -> bool {
let (a, b) = (a.as_bytes(), b.as_bytes()); let (a, b) = (a.as_bytes(), b.as_bytes());
if a.len() != b.len() { if a.len() != b.len() {

View File

@@ -234,3 +234,25 @@ test.describe('on a phone', () => {
await expect(page.locator('#detail')).not.toBeVisible(); 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([]);
});

View File

@@ -80,7 +80,15 @@ a{color:var(--accent)}
} }
.sidetools button:hover,.sidefoot button:hover{border-color:var(--accent);color:var(--fg)} .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. */ /* 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 button{display:inline-flex;align-items:center;justify-content:center;gap:6px}
.sidefoot i{font-style:normal;opacity:.75} .sidefoot i{font-style:normal;opacity:.75}
.searchwrap{padding:0 12px 8px} .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 class="searchwrap"><input type="search" id="feedFilter" placeholder="Filter feeds…"></div>
<div id="feedlist"></div> <div id="feedlist"></div>
<div class="sidefoot"> <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="prefs" title="Settings"><i></i>Settings</button>
<button id="logs" title="Daemon and web log"><i></i>Log</button> <button id="logs" title="Daemon and web log"><i></i>Log</button>
</div> </div>
</div>
</aside> </aside>
<div id="main"> <div id="main">
<div id="mbar"> <div id="mbar">
@@ -422,6 +433,7 @@ const esc = s => (s??'').replace(/[&<>"']/g,c=>({'&':'&amp;','<':'&lt;','>':'&gt
async function api(url,opts){ async function api(url,opts){
const r = await fetch(url,{headers:{'Content-Type':'application/json'},...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); if(!r.ok) throw new Error(await r.text().catch(()=>r.status)||r.status);
return r.status===204?null:r.json().catch(()=>null); 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; } 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. /// 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){ function selectEntry(e){
S.sel=e.guid; S.sel=e.guid;
markRead(e);
$$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid)); $$('#eps .ep').forEach(x=>x.classList.toggle('sel', x.dataset.guid===e.guid));
swapRow(e);
showDetail(e); showDetail(e);
const d=$('#detail'); if(d) d.scrollTop=0; const d=$('#detail'); if(d) d.scrollTop=0;
} }
@@ -861,8 +891,8 @@ function encBox(x){
} }
async function epAction(a,e,el,encId){ 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. // Swap the row in place and refresh the text below when it is the one being read.
const redraw=()=>{ if(el) redraw(); else renderEntries(); showDetail(e); }; 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 enc=(encId!=null && e.enclosures.find(x=>x.id===encId)) || e.enclosures[0];
const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`; const path=`/api/entries/${encodeURIComponent(e.feed_id)}/${encodeURIComponent(e.guid)}`;
try{ 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})}); }; $('#scanAll').onclick=async()=>{ toast('Scanning all feeds…'); await api('/api/fetch',{method:'POST',body:JSON.stringify({force:true})}); };
on('#prefs','onclick',prefsModal); 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); on('#logs','onclick',logsModal);
$('#feedFilter').oninput=renderFeeds; $('#feedFilter').oninput=renderFeeds;
$('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open')); $('#burger').onclick=()=>nav(!$('#sidebar').classList.contains('open'));

91
web/login.html Normal file

File diff suppressed because one or more lines are too long