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:
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> {
|
||||
|
||||
Reference in New Issue
Block a user