From 56c5b71881ed0b7bdcf25c8a2e2cab3e75232ec3 Mon Sep 17 00:00:00 2001 From: trisua Date: Sat, 30 Aug 2025 11:27:44 -0400 Subject: [PATCH] add: users table --- README.md | 4 +- src/config.rs | 2 +- src/database/mod.rs | 3 +- src/database/sql/create_users.sql | 13 +++ src/database/sql/mod.rs | 2 +- src/database/users.rs | 161 ++++++++++++++++++++++++++++++ src/main.rs | 11 +- src/markdown.rs | 18 ---- src/model.rs | 74 +++++++++++++- 9 files changed, 253 insertions(+), 35 deletions(-) create mode 100644 src/database/sql/create_users.sql create mode 100644 src/database/users.rs delete mode 100644 src/markdown.rs diff --git a/README.md b/README.md index 59180a5..943e937 100644 --- a/README.md +++ b/README.md @@ -1,3 +1 @@ -# 🪨 malachite - -simple template for building backends with a structure similar to how the [tetratto](https://trisua.com/t/tetratto) repository is organized +# 🧁 Pastry.diy diff --git a/src/config.rs b/src/config.rs index 3866212..e1e46d0 100644 --- a/src/config.rs +++ b/src/config.rs @@ -19,7 +19,7 @@ pub struct Config { } fn default_name() -> String { - "App".to_string() + "Pastry.diy".to_string() } fn default_theme_color() -> String { diff --git a/src/database/mod.rs b/src/database/mod.rs index 71ffaad..ed0a383 100644 --- a/src/database/mod.rs +++ b/src/database/mod.rs @@ -1,4 +1,5 @@ mod sql; +mod users; use crate::config::Config; use oiseau::{execute, postgres::DataManager as OiseauManager, postgres::Result as PgResult}; @@ -22,7 +23,7 @@ impl DataManager { Err(e) => return Err(Error::DatabaseConnection(e.to_string())), }; - // execute!(&conn, sql::CREATE_TABLE_ENTRIES).unwrap(); + execute!(&conn, sql::CREATE_TABLE_USERS).unwrap(); Ok(()) } diff --git a/src/database/sql/create_users.sql b/src/database/sql/create_users.sql new file mode 100644 index 0000000..05ec5fd --- /dev/null +++ b/src/database/sql/create_users.sql @@ -0,0 +1,13 @@ +CREATE TABLE IF NOT EXISTS p_users ( + id TEXT NOT NULL, + created BIGINT NOT NULL, + username TEXT NOT NULL, + password TEXT NOT NULL, + salt TEXT NOT NULL, + sessions TEXT NOT NULL, + username_changed BIGINT NOT NULL, + role TEXT NOT NULL, + banned INT NOT NULL, + settings TEXT NOT NULL, + legacy_token TEXT NOT NULL, -- to remove in the future +) diff --git a/src/database/sql/mod.rs b/src/database/sql/mod.rs index a02070e..49c0e90 100644 --- a/src/database/sql/mod.rs +++ b/src/database/sql/mod.rs @@ -1 +1 @@ -// pub const CREATE_TABLE_ENTRIES: &str = include_str!("./create_entries.sql"); +pub const CREATE_TABLE_USERS: &str = include_str!("./create_users.sql"); diff --git a/src/database/users.rs b/src/database/users.rs new file mode 100644 index 0000000..be545a0 --- /dev/null +++ b/src/database/users.rs @@ -0,0 +1,161 @@ +use super::DataManager; +use crate::{ + database::NAME_REGEX, + model::{User, UserSession, UserSettings}, +}; +use oiseau::{PostgresRow, cache::Cache, execute, get, params, query_row}; +use tetratto_core::{ + auto_method, + model::{Error, Result}, +}; +use tetratto_shared::{ + hash::{hash, salt}, + unix_epoch_timestamp, +}; + +impl DataManager { + /// Get a [`User`] from an SQL row. + pub(crate) fn get_user_from_row(x: &PostgresRow) -> User { + User { + id: get!(x->0(String)), + created: get!(x->1(i64)) as usize, + username: get!(x->2(String)), + password: get!(x->3(String)), + salt: get!(x->4(String)), + sessions: serde_json::from_str(&get!(x->5(String))).unwrap(), + username_changed: get!(x->6(i64)) as usize, + role: serde_json::from_str(&get!(x->7(String))).unwrap(), + banned: get!(x->8(i32)) == 1, + settings: serde_json::from_str(&get!(x->8(String))).unwrap(), + legacy_token: get!(x->9(String)), + } + } + + auto_method!(get_user_by_id(usize as i64)@get_user_from_row -> "SELECT * FROM p_users WHERE id = $1" --name="user" --returns=User --cache-key-tmpl="pstr.user:{}"); + auto_method!(get_user_by_username(&str)@get_user_from_row -> "SELECT * FROM p_users WHERE username = $1" --name="user" --returns=User --cache-key-tmpl="pstr.user:{}"); + + /// Create a new user in the database. + /// + /// # Arguments + /// * `data` - a mock [`User`] object to insert + pub async fn create_user(&self, data: User) -> Result { + // check values + if data.username.trim().len() < 2 { + return Err(Error::DataTooShort("username".to_string())); + } else if data.username.len() > 32 { + return Err(Error::DataTooLong("username".to_string())); + } + + // check characters + let regex = regex::RegexBuilder::new(NAME_REGEX) + .multi_line(true) + .build() + .unwrap(); + + if regex.captures(&data.username).is_some() { + return Err(Error::MiscError( + "Username contains invalid characters".to_string(), + )); + } + + // ... + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "INSERT INTO p_users VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11)", + params![ + &data.id, + &(data.created as i64), + &data.username, + &data.password, + &data.salt, + &serde_json::to_string(&data.sessions).unwrap(), + &(data.username_changed as i64), + &serde_json::to_string(&data.role).unwrap(), + &if data.banned { 1_i32 } else { 0_i32 }, + &serde_json::to_string(&data.settings).unwrap(), + &data.legacy_token + ] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + Ok(data) + } + + pub async fn update_user_password(&self, id: usize, x: String) -> Result<()> { + let y = self.get_user_by_id(id).await?; + + let new_salt = salt(); + let hashed = hash(x + &new_salt); + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET password = $1, salt = $2 WHERE id = $2", + params![&hashed, &new_salt, &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&y).await; + + Ok(()) + } + + const USERNAME_CHANGE_WAIT_TIME: usize = 604800000; // 1 week + pub async fn update_user_username(&self, id: usize, x: String) -> Result<()> { + let y = self.get_user_by_id(id).await?; + let now = unix_epoch_timestamp(); + + if now - y.username_changed < Self::USERNAME_CHANGE_WAIT_TIME { + return Err(Error::MiscError( + "Username changed too recently".to_string(), + )); + } + + let conn = match self.0.connect().await { + Ok(c) => c, + Err(e) => return Err(Error::DatabaseConnection(e.to_string())), + }; + + let res = execute!( + &conn, + "UPDATE users SET username = $1, username_changed = $2 WHERE id = $2", + params![&x, &(now as i64), &(id as i64)] + ); + + if let Err(e) = res { + return Err(Error::DatabaseError(e.to_string())); + } + + self.cache_clear_user(&y).await; + + Ok(()) + } + + pub async fn cache_clear_user(&self, user: &User) -> bool { + self.0.1.remove(format!("pstr.user:{}", user.id)).await + && self + .0 + .1 + .remove(format!("pstr.user:{}", user.username)) + .await + } + + auto_method!(update_user_banned(i32)@get_user_by_id -> "UPDATE users SET banned = $1 WHERE id = $2" --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_settings(UserSettings)@get_user_by_id -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); + auto_method!(update_user_sessions(Vec)@get_user_by_id -> "UPDATE users SET sessions = $1 WHERE id = $2" --serde --cache-key-tmpl=cache_clear_user); +} diff --git a/src/main.rs b/src/main.rs index 04cdfe8..0e914dc 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,7 +1,6 @@ #![doc = include_str!("../README.md")] mod config; mod database; -mod markdown; mod model; mod routes; @@ -23,13 +22,6 @@ use tracing::{Level, info}; pub(crate) type InnerState = (DataManager, Tera, String); pub(crate) type State = Arc>; -fn render_markdown(value: &Value, _: &HashMap) -> tera::Result { - Ok(markdown::render_markdown(value.as_str().unwrap()) - .replace("\\@", "@") - .replace("%5C@", "@") - .into()) -} - fn remove_script_tags(value: &Value, _: &HashMap) -> tera::Result { Ok(value .as_str() @@ -100,7 +92,6 @@ async fn main() { } }; - tera.register_filter("markdown", render_markdown); tera.register_filter("remove_script_tags", remove_script_tags); // create app @@ -125,7 +116,7 @@ async fn main() { .await .unwrap(); - info!("🪨 malachite."); + info!("🧁 pastry.diy."); info!("listening on http://0.0.0.0:{}", port); axum::serve( listener, diff --git a/src/markdown.rs b/src/markdown.rs deleted file mode 100644 index 44fc157..0000000 --- a/src/markdown.rs +++ /dev/null @@ -1,18 +0,0 @@ -use std::collections::HashSet; - -pub fn render_markdown(input: &str) -> String { - let html = tetratto_shared::markdown::render_markdown_dirty(input); - - let mut allowed_attributes = HashSet::new(); - allowed_attributes.insert("id"); - allowed_attributes.insert("class"); - allowed_attributes.insert("ref"); - allowed_attributes.insert("aria-label"); - allowed_attributes.insert("lang"); - allowed_attributes.insert("title"); - allowed_attributes.insert("align"); - allowed_attributes.insert("src"); - allowed_attributes.insert("style"); - - tetratto_shared::markdown::clean_html(html, allowed_attributes) -} diff --git a/src/model.rs b/src/model.rs index 83af5d7..505f1da 100644 --- a/src/model.rs +++ b/src/model.rs @@ -1 +1,73 @@ -//! Base types matching SQL table structures. +use serde::{Deserialize, Serialize}; +use tetratto_shared::{ + hash::{hash, salt, uuid}, + unix_epoch_timestamp, +}; + +#[derive(Deserialize, Serialize, PartialEq, Eq)] +pub enum UserRole { + #[serde(alias = "user")] + User, + #[serde(alias = "admin")] + Admin, +} + +/// `(hashed token, ip, created)` +pub type UserSession = (String, String, usize); + +#[derive(Serialize, Deserialize)] +pub struct User { + pub id: String, + pub created: usize, + pub username: String, + pub password: String, + pub salt: String, + pub sessions: Vec, + /// The time at which the user's username was last changed. + /// + /// Users can only change their username once a week. + pub username_changed: usize, + pub role: UserRole, + pub banned: bool, + pub settings: UserSettings, + /// The user's old log-in method. Users with a token and NOT a password + /// will be prompted to add a password to the account. + pub legacy_token: String, +} + +#[derive(Serialize, Deserialize, Default)] +pub struct UserSettings { + /// The user's biography text. + pub bio: String, + /// The CSS string embeded on the user's profile. + pub css: String, + /// The URL of the user's avatar. + pub avatar_url: String, +} + +impl User { + /// Create a new [`User`]. + pub fn new(username: String, unhashed_password: String) -> Self { + let salt = salt(); + let password = hash(unhashed_password + &salt); + + Self { + id: uuid(), + created: unix_epoch_timestamp(), + username, + password, + salt, + sessions: Vec::new(), + username_changed: 0, + role: UserRole::User, + banned: false, + settings: UserSettings::default(), + legacy_token: String::new(), + } + } + + /// Check the given password against the user's password. + pub fn check_password(&self, password: String) -> bool { + hash(password + &self.salt) == self.password + } +}