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>