~whbboyd/russet

dffcd7735c4aa4b6189212fb4ef20f7c0ab5359f — Will Boyd 3 months ago 52e1bae
Checkpoint: user pages.
M .env => .env +1 -1
@@ 1,1 1,1 @@
DATABASE_URL=sqlite:template_db.sqlite
DATABASE_URL=sqlite:db/template_db.sqlite

A db/migrations/3_User_settings.sql => db/migrations/3_User_settings.sql +25 -0
@@ 0,0 1,25 @@
-- Additional user fields
-- This remains dumb because sqlite is dumb

CREATE TABLE users_temp (
	id TEXT NOT NULL PRIMARY KEY,
	name TEXT NOT NULL,
	password_hash TEXT NOT NULL,
	user_type TEXT NOT NULL
);

INSERT INTO users_temp (
	id, name, password_hash, user_type
) SELECT id, name, password_hash, 'Member'
	FROM users;

DROP TABLE users;

ALTER TABLE users_temp RENAME TO users;

-- A note about 1_User_entry_settings.sql: that script is buggy, and the order
-- of operations here (create new, copy rows, drop original, rename new) is
-- correct. Otherwise, sqlite will corrupt references to the original table when
-- you rename it. (Why can you drop a table with active foreign keys against it?
-- Truly a question for the ages.)


R template_db.sqlite => db/template_db.sqlite +0 -0
M src/conf.rs => src/conf.rs +2 -0
@@ 1,4 1,5 @@
use clap::{ Args, Parser, Subcommand};
use crate::model::UserType;
use merge::Merge;
use serde::Deserialize;
use std::num::ParseIntError;


@@ 92,6 93,7 @@ pub enum Command {
	AddUser {
		user_name: String,
		password: Option<String>,
		user_type: Option<UserType>,
	},

	/// Reset a user's password

M src/domain/user.rs => src/domain/user.rs +12 -2
@@ 4,7 4,7 @@ use argon2::password_hash::rand_core::OsRng;
use argon2::password_hash::SaltString;
use base32ct::{ Base32Unpadded, Encoding };
use crate::domain::RussetDomainService;
use crate::model::{ FeedId, Timestamp, UserId };
use crate::model::{ FeedId, Timestamp, UserId, UserType };
use crate::persistence::model::{ PasswordHash, Session, SessionToken, User };
use crate::persistence::RussetUserPersistenceLayer;
use crate::Err;


@@ 73,7 73,12 @@ where Persistence: RussetUserPersistenceLayer {
		}
	}

	pub async fn add_user(&self, user_name: &str, plaintext_password: &str) -> Result<()> {
	pub async fn add_user(
		&self,
		user_name: &str,
		plaintext_password: &str,
		user_type: UserType
	) -> Result<()> {
		if let Some(user) = self.persistence.get_user_by_name(&user_name).await? {
			return Err(format!("User {} ({}) already exists", user.name, user.id.to_string()).into());
		}


@@ 82,6 87,7 @@ where Persistence: RussetUserPersistenceLayer {
			id: UserId(Ulid::new()),
			name: user_name.to_string(),
			password_hash,
			user_type,
		};
		self.persistence.add_user(&user).await?;
		Ok(())


@@ 141,6 147,10 @@ where Persistence: RussetUserPersistenceLayer {
		}
	}

	pub async fn get_user(&self, user_id: &UserId) -> Result<User> {
		self.persistence.get_user(user_id).await
	}

	pub async fn subscribe(&self, user_id: &UserId, feed_id: &FeedId) -> Result<()> {
		self.persistence.add_subscription(user_id, feed_id).await
	}

M src/http/mod.rs => src/http/mod.rs +2 -0
@@ 18,6 18,7 @@ mod root;
mod session;
mod static_routes;
mod subscribe;
mod user;

pub fn russet_router<Persistence>(
	global_concurrent_limit: u32,


@@ 34,6 35,7 @@ where Persistence: RussetPersistenceLayer {
		.route("/", get(root::root).post(root::edit_userentries))
		.route("/entry/:id", get(entry::mark_read_redirect))
		.route("/feed/:id", get(feed::feed_page).post(feed::unsubscribe))
		.route("/user/:id", get(user::user_page))
		.route("/subscribe", get(subscribe::subscribe_page).post(subscribe::subscribe))
		.route("/*any", any(|| async { Redirect::to("/") }))
		.layer(GlobalConcurrencyLimitLayer::with_semaphore(global_limit_semaphore))

M src/http/session.rs => src/http/session.rs +1 -1
@@ 39,7 39,7 @@ where
					None => Err(Redirect::to("/login").into_response()),
				}
			},
			// Session cookies is missing user needs to autheticate
			// Session cookies is missing: user needs to authenticate
			None => Err(Redirect::to("/login").into_response()),
		}
	}

A src/http/user.rs => src/http/user.rs +21 -0
@@ 0,0 1,21 @@
use axum::extract::{ Path, State };
use axum::response::Html;
use crate::http::{ AppState, AuthenticatedUser };
use crate::model::{ UserId, UserType };
use crate::persistence::RussetPersistenceLayer;

#[tracing::instrument]
pub async fn user_page<Persistence>(
	Path(user_id): Path<UserId>,
	State(state): State<AppState<Persistence>>,
	auth_user: AuthenticatedUser<Persistence>,
) -> Html<String>
where Persistence: RussetPersistenceLayer {
	// Authentication rules. Sysops can see all user pages. Members can see only
	// themselves.
	if auth_user.user.user_type != UserType::Sysop && auth_user.user.id != user_id {
		panic!("PERMISSION DENIED!!!1!!");
	}
	let user = state.domain_service.get_user(&user_id).await.unwrap();
	Html(format!("User: {}<br />ID: {:?}<br />Type: {:?}", user.name, user.id, user.user_type))
}

M src/main.rs => src/main.rs +17 -5
@@ 18,6 18,7 @@ use crate::domain::RussetDomainService;
use crate::feed::atom::AtomFeedReader;
use crate::feed::rss::RssFeedReader;
use crate::feed::RussetFeedReader;
use crate::model::UserType;
use crate::persistence::sql::SqlDatabase;
use crate::server::start;
use merge::Merge;


@@ 100,19 101,30 @@ async fn main() -> Result<()> {
				login_concurrent_limit
			)
			.await?,
		Command::AddUser { user_name, password } => {
		Command::AddUser { user_name, password, user_type } => {
			info!("Adding user {user_name}…");
			let plaintext_password = match password {
				Some(password) => password,
				None => prompt_password(format!("Enter password for {}: ", user_name))?,
				None => prompt_password(format!("Enter password for {user_name}: "))?,
			};
			domain_service.add_user(&user_name, &plaintext_password).await?;
			let user_type = match user_type {
				Some(user_type) => user_type,
				None => {
					print!("Enter user type (\"Member\" or \"Sysop\"): ");
					use std::io::Write;
					std::io::stdout().flush()?;
					let mut user_type_str = String::new();
					std::io::stdin().read_line(&mut user_type_str)?;
					user_type_str.trim_end().to_string().try_into()?
				}
			};
			domain_service.add_user(&user_name, &plaintext_password, user_type).await?;
		},
		Command::SetUserPassword { user_name, password } => {
			info!("Setting password for user {user_name}…");
			let plaintext_password = match password {
				Some(password) => password,
				None => prompt_password(format!("Enter password for {}: ", user_name))?,
				None => prompt_password(format!("Enter password for {user_name}: "))?,
			};
			domain_service
				.set_user_password(&user_name, &plaintext_password)


@@ 123,7 135,7 @@ async fn main() -> Result<()> {
			domain_service.delete_user(&user_name).await?;
		}
		Command::DeleteSessions { user_name } => {
			info!("Delete sessions for {user_name}…");
			info!("Deleting sessions for {user_name}…");
			domain_service.delete_user_sessions(&user_name).await?;
		}
		_ => { warn!("Not yet implemented") },

M src/model.rs => src/model.rs +30 -6
@@ 1,11 1,13 @@
/// Utility types

use clap::ValueEnum;
use crate::{ Err, Result };
use serde::Deserialize;
use std::ops::Deref;
use std::time::SystemTime;
use ulid::Ulid;

#[derive(Clone, Deserialize)]
#[derive(Clone, Copy, Deserialize)]
pub struct Timestamp(pub SystemTime);
impl Timestamp {
	pub fn new(time: SystemTime) -> Timestamp {


@@ 18,13 20,13 @@ impl std::fmt::Debug for Timestamp {
	}
}

#[derive(Clone, Deserialize, Debug)]
#[derive(Clone, Copy, Deserialize, Debug)]
pub struct Pagination {
	pub page_num: usize,
	pub page_size: usize,
}

#[derive(Clone, Deserialize, Eq, Hash, PartialEq)]
#[derive(Clone, Copy, Deserialize, Eq, Hash, PartialEq)]
pub struct FeedId(pub Ulid);
impl Deref for FeedId { type Target = Ulid; fn deref(&self) -> &Self::Target { &self.0 } }
impl std::fmt::Debug for FeedId {


@@ 32,7 34,7 @@ impl std::fmt::Debug for FeedId {
		f.write_fmt(format_args!("\"{}\"", &self.to_string()))
	}
}
#[derive(Clone, Deserialize)]
#[derive(Clone, Copy, Deserialize)]
pub struct EntryId(pub Ulid);
impl Deref for EntryId{ type Target = Ulid; fn deref(&self) -> &Self::Target { &self.0 } }
impl std::fmt::Debug for EntryId {


@@ 40,7 42,7 @@ impl std::fmt::Debug for EntryId {
		f.write_fmt(format_args!("\"{}\"", &self.to_string()))
	}
}
#[derive(Clone)]
#[derive(Clone, Copy, Deserialize, PartialEq, Eq)]
pub struct UserId(pub Ulid);
impl Deref for UserId{ type Target = Ulid; fn deref(&self) -> &Self::Target { &self.0 } }
impl std::fmt::Debug for UserId {


@@ 48,4 50,26 @@ impl std::fmt::Debug for UserId {
		f.write_fmt(format_args!("\"{}\"", &self.to_string()))
	}
}

#[derive(Clone, Copy, Deserialize, Debug, PartialEq, Eq, ValueEnum)]
pub enum UserType {
	Sysop,
	Member,
}
impl TryFrom<String> for UserType {
	type Error = Err;
	fn try_from(str: String) -> Result<UserType> {
		match str.as_str() {
			"Sysop" => Ok(UserType::Sysop),
			"Member" => Ok(UserType::Member),
			_ => Err(format!("Unrecognized value {str} (must be one of \"Sysop\", \"Member\")").into()),
		}
	}
}
impl Into<String> for UserType {
	fn into(self) -> String {
		match self {
			UserType::Sysop => "Sysop".to_string(),
			UserType::Member => "Member".to_string(),
		}
	}
}

M src/persistence/mod.rs => src/persistence/mod.rs +4 -0
@@ 75,6 75,10 @@ pub trait RussetEntryPersistenceLayer: Send + Sync + std::fmt::Debug + 'static {
}

pub trait RussetUserPersistenceLayer: Send + Sync + std::fmt::Debug + 'static {

	/// Get the [User] with the given [UserId]
	fn get_user(&self, user_id: &UserId) -> impl Future<Output = Result<User>> + Send;

	/// Add the given [User] to the persistence layer
	async fn add_user(&self, user: &User) -> Result<()>;


M src/persistence/model.rs => src/persistence/model.rs +2 -1
@@ 1,4 1,4 @@
use crate::model::{ EntryId, FeedId, UserId, Timestamp };
use crate::model::{ EntryId, FeedId, UserId, UserType, Timestamp };
use reqwest::Url;

/// Metadata for a feed, e.g. title and feed URL


@@ 33,6 33,7 @@ pub struct User {
	pub id: UserId,
	pub name: String,
	pub password_hash: PasswordHash,
	pub user_type: UserType,
}

#[derive(Clone)]

M src/persistence/sql/user.rs => src/persistence/sql/user.rs +33 -8
@@ 8,16 8,39 @@ use ulid::Ulid;
impl RussetUserPersistenceLayer for SqlDatabase {

	#[tracing::instrument]
	async fn get_user(&self, user_id: &UserId) -> Result<User> {
		let user_id = user_id.to_string();
		let row = sqlx::query!("
				SELECT
					id, name, password_hash, user_type
				FROM users
				WHERE id = ?;",
				user_id)
			.fetch_one(&self.pool)
			.await?;
		let id = UserId(Ulid::from_string(&row.id)?);
		let password_hash = PasswordHash(row.password_hash);
		Ok(User {
			id,
			name: row.name,
			password_hash,
			user_type: row.user_type.try_into()?,
		} )
	}

	#[tracing::instrument]
	async fn add_user(&self, user: &User) -> Result<()> {
		let user_id = user.id.to_string();
		let password_hash = &user.password_hash.0;
		let user_type: String = user.user_type.into();
		sqlx::query!("
				INSERT INTO users (
					id, name, password_hash
				) VALUES ( ?, ?, ? );",
					id, name, password_hash, user_type
				) VALUES ( ?, ?, ?, ? );",
				user_id,
				user.name,
				password_hash,
				user_type,
			)
			.execute(&self.pool)
			.await?;


@@ 68,7 91,7 @@ impl RussetUserPersistenceLayer for SqlDatabase {
	async fn get_user_by_name(&self, user_name: &str) -> Result<Option<User>> {
		let row_result = sqlx::query!("
				SELECT
					id, name, password_hash
					id, name, password_hash, user_type
				FROM users
				WHERE name = ?;",
				user_name)


@@ 82,6 105,7 @@ impl RussetUserPersistenceLayer for SqlDatabase {
					id,
					name: row.name,
					password_hash,
					user_type: row.user_type.try_into()?,
				} ) )
			},
			Err(sqlx::Error::RowNotFound) => Ok(None),


@@ 92,7 116,7 @@ impl RussetUserPersistenceLayer for SqlDatabase {
	#[tracing::instrument]
	async fn add_session(&self, session: &Session) -> Result<()> {
		let user_id = session.user_id.to_string();
		let expiration = TryInto::<i64>::try_into(session.expiration.clone())?;
		let expiration: i64 = session.expiration.clone().try_into()?;
		sqlx::query!("
				INSERT INTO sessions (
					token, user_id, expiration


@@ 110,7 134,7 @@ impl RussetUserPersistenceLayer for SqlDatabase {
	async fn get_user_by_session(&self, session_token: &str) -> Result<Option<(User, Session)>> {
		let row_result = sqlx::query!("
				SELECT
					users.id, users.name, users.password_hash,
					users.id, users.name, users.password_hash, users.user_type,
					sessions.expiration
				FROM users
				JOIN sessions


@@ 128,6 152,7 @@ impl RussetUserPersistenceLayer for SqlDatabase {
						id: user_id.clone(),
						name: row.name,
						password_hash,
						user_type: row.user_type.try_into()?,
					},
					Session {
						token: SessionToken(session_token.to_string()),


@@ 159,12 184,12 @@ impl RussetUserPersistenceLayer for SqlDatabase {
	}

	#[tracing::instrument]
	async fn delete_expired_sessions(&self, expiry: &Timestamp) -> Result<()> {
		let expiry = TryInto::<i64>::try_into(expiry.clone())?;
	async fn delete_expired_sessions(&self, expiration: &Timestamp) -> Result<()> {
		let expiration: i64 = expiration.clone().try_into()?;
		sqlx::query!("
				DELETE FROM sessions
				wHERE expiration < ?;",
				expiry,
				expiration,
			)
			.execute(&self.pool)
			.await?

M templates/head.stpl => templates/head.stpl +9 -2
@@ 9,7 9,14 @@
		<div id="header">
			<span id="header-app-title"><a href="<%- relative_root %>"><%- crate::APP_NAME %></a></span>
			<span id="header-page-title"><%= page_title %></span>
			<span id="header-user-info"><%=
user.map(|user| format!("User: {}", user.name)).unwrap_or("".to_string())
			<span id="header-user-info"><%
match user {
	Some(user) => {
%><a href="<%- relative_root %>user/<%- user.id.to_string() %>">User: <%= user.name %></a><%
	}
	None => {
%><%
	}
}
%></span>
		</div>