- ${option.label.replaceAll("_", " ")}
+
@@ -647,6 +657,8 @@ media_theme_pref();
type="text"
onchange="window.set_setting_field('${option.key}', event.target.value)"
placeholder="${option.key}"
+ name="${option.key}"
+ id="${option.key}"
${option.input_element_type === "input" ? `value="${option.value}"/>` : ">"}
${option.input_element_type === "textarea" ? `${option.value}` : ""}
diff --git a/crates/app/src/routes/api/v1/auth/images.rs b/crates/app/src/routes/api/v1/auth/images.rs
index a0dcb48..bd2954d 100644
--- a/crates/app/src/routes/api/v1/auth/images.rs
+++ b/crates/app/src/routes/api/v1/auth/images.rs
@@ -72,8 +72,11 @@ pub async fn avatar_request(
}
};
- let path =
- PathBufD::current().extend(&["avatars", &data.0.dirs.media, &format!("{}.avif", &user.id)]);
+ let path = PathBufD::current().extend(&[
+ data.0.dirs.media.as_str(),
+ "avatars",
+ &format!("{}.avif", &user.id),
+ ]);
if !exists(&path).unwrap() {
return (
@@ -114,8 +117,11 @@ pub async fn banner_request(
}
};
- let path =
- PathBufD::current().extend(&["banners", &data.0.dirs.media, &format!("{}.avif", &user.id)]);
+ let path = PathBufD::current().extend(&[
+ data.0.dirs.media.as_str(),
+ "banners",
+ &format!("{}.avif", &user.id),
+ ]);
if !exists(&path).unwrap() {
return (
diff --git a/crates/app/src/routes/api/v1/auth/profile.rs b/crates/app/src/routes/api/v1/auth/profile.rs
index 1c87600..abd3de7 100644
--- a/crates/app/src/routes/api/v1/auth/profile.rs
+++ b/crates/app/src/routes/api/v1/auth/profile.rs
@@ -1,7 +1,7 @@
use crate::{
State, get_user_from_token,
model::{ApiReturn, Error},
- routes::api::v1::UpdateUserIsVerified,
+ routes::api::v1::{UpdateUserIsVerified, UpdateUserPassword, UpdateUserUsername},
};
use axum::{
Extension, Json,
@@ -59,6 +59,70 @@ pub async fn update_profile_settings_request(
}
}
+/// Update the password of the given user.
+pub async fn update_profile_password_request(
+ jar: CookieJar,
+ Path(id): Path
,
+ Extension(data): Extension,
+ Json(req): Json,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ if user.id != id {
+ if !user.permissions.check(FinePermission::MANAGE_USERS) {
+ return Json(Error::NotAllowed.into());
+ }
+ }
+
+ match data
+ .update_user_password(id, req.from, req.to, user, false)
+ .await
+ {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Password updated".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
+
+pub async fn update_profile_username_request(
+ jar: CookieJar,
+ Path(id): Path,
+ Extension(data): Extension,
+ Json(req): Json,
+) -> impl IntoResponse {
+ let data = &(data.read().await).0;
+ let user = match get_user_from_token!(jar, data) {
+ Some(ua) => ua,
+ None => return Json(Error::NotAllowed.into()),
+ };
+
+ if user.id != id {
+ if !user.permissions.check(FinePermission::MANAGE_USERS) {
+ return Json(Error::NotAllowed.into());
+ }
+ }
+
+ if data.get_user_by_username(&req.to).await.is_ok() {
+ return Json(Error::UsernameInUse.into());
+ }
+
+ match data.update_user_username(id, req.to, user).await {
+ Ok(_) => Json(ApiReturn {
+ ok: true,
+ message: "Username updated".to_string(),
+ payload: (),
+ }),
+ Err(e) => Json(e.into()),
+ }
+}
+
/// Update the tokens of the given user.
pub async fn update_profile_tokens_request(
jar: CookieJar,
diff --git a/crates/app/src/routes/api/v1/communities/images.rs b/crates/app/src/routes/api/v1/communities/images.rs
index 4ebbbac..8907740 100644
--- a/crates/app/src/routes/api/v1/communities/images.rs
+++ b/crates/app/src/routes/api/v1/communities/images.rs
@@ -34,8 +34,8 @@ pub async fn avatar_request(
};
let path = PathBufD::current().extend(&[
+ data.0.dirs.media.as_str(),
"community_avatars",
- &data.0.dirs.media,
&format!("{}.avif", &community.id),
]);
@@ -79,8 +79,8 @@ pub async fn banner_request(
};
let path = PathBufD::current().extend(&[
+ data.0.dirs.media.as_str(),
"community_banners",
- &data.0.dirs.media,
&format!("{}.avif", &community.id),
]);
@@ -132,7 +132,7 @@ pub async fn upload_avatar_request(
let path = pathd!(
"{}/community_avatars/{}.avif",
data.0.dirs.media,
- &auth_user.id
+ &community.id
);
// check file size
@@ -188,7 +188,7 @@ pub async fn upload_banner_request(
let path = pathd!(
"{}/community_banners/{}.avif",
data.0.dirs.media,
- &auth_user.id
+ &community.id
);
// check file size
diff --git a/crates/app/src/routes/api/v1/mod.rs b/crates/app/src/routes/api/v1/mod.rs
index 437cafb..dd3d399 100644
--- a/crates/app/src/routes/api/v1/mod.rs
+++ b/crates/app/src/routes/api/v1/mod.rs
@@ -109,6 +109,14 @@ pub fn routes() -> Router {
"/auth/profile/{id}/settings",
post(auth::profile::update_profile_settings_request),
)
+ .route(
+ "/auth/profile/{id}/password",
+ post(auth::profile::update_profile_password_request),
+ )
+ .route(
+ "/auth/profile/{id}/username",
+ post(auth::profile::update_profile_username_request),
+ )
.route(
"/auth/profile/{id}/tokens",
post(auth::profile::update_profile_tokens_request),
@@ -189,6 +197,17 @@ pub struct CreateReaction {
pub is_like: bool,
}
+#[derive(Deserialize)]
+pub struct UpdateUserPassword {
+ pub from: String,
+ pub to: String,
+}
+
+#[derive(Deserialize)]
+pub struct UpdateUserUsername {
+ pub to: String,
+}
+
#[derive(Deserialize)]
pub struct UpdateUserIsVerified {
pub is_verified: bool,
diff --git a/crates/app/src/routes/pages/communities.rs b/crates/app/src/routes/pages/communities.rs
index 371d454..6cf9821 100644
--- a/crates/app/src/routes/pages/communities.rs
+++ b/crates/app/src/routes/pages/communities.rs
@@ -80,15 +80,6 @@ pub fn community_context(
context.insert("community", &community);
context.insert("is_owner", &is_owner);
context.insert("is_joined", &is_joined);
-
- if is_owner {
- context.insert(
- "community_context_serde",
- &serde_json::to_string(&community.context)
- .unwrap()
- .replace("\"", "\\\""),
- );
- }
}
/// `/community/{title}`
@@ -152,6 +143,53 @@ pub async fn feed_request(
))
}
+/// `/community/{title}/manage`
+pub async fn settings_request(
+ jar: CookieJar,
+ Path(title): Path,
+ Extension(data): Extension,
+) -> impl IntoResponse {
+ let data = data.read().await;
+ let user = match get_user_from_token!(jar, data.0) {
+ Some(ua) => ua,
+ None => {
+ return Err(Html(
+ render_error(Error::NotAllowed, &jar, &data, &None).await,
+ ));
+ }
+ };
+
+ let community = match data.0.get_community_by_title(&title.to_lowercase()).await {
+ Ok(ua) => ua,
+ Err(e) => return Err(Html(render_error(e, &jar, &data, &Some(user)).await)),
+ };
+
+ if user.id != community.owner {
+ return Err(Html(
+ render_error(Error::NotAllowed, &jar, &data, &None).await,
+ ));
+ }
+
+ // init context
+ let lang = get_lang!(jar, data.0);
+ let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
+
+ context.insert("community", &community);
+ context.insert(
+ "community_context_serde",
+ &serde_json::to_string(&community.context)
+ .unwrap()
+ .replace("\"", "\\\""),
+ );
+
+ // return
+ Ok(Html(
+ data.1
+ .render("communities/settings.html", &mut context)
+ .unwrap(),
+ ))
+}
+
/// `/post/{id}`
pub async fn post_request(
jar: CookieJar,
diff --git a/crates/app/src/routes/pages/misc.rs b/crates/app/src/routes/pages/misc.rs
index 751512e..8dba087 100644
--- a/crates/app/src/routes/pages/misc.rs
+++ b/crates/app/src/routes/pages/misc.rs
@@ -7,6 +7,20 @@ use axum::{
use axum_extra::extract::CookieJar;
use tetratto_core::model::Error;
+pub async fn not_found(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse {
+ let data = data.read().await;
+ let user = get_user_from_token!(jar, data.0);
+ Html(
+ render_error(
+ Error::GeneralNotFound("page".to_string()),
+ &jar,
+ &data,
+ &user,
+ )
+ .await,
+ )
+}
+
/// `/`
pub async fn index_request(jar: CookieJar, Extension(data): Extension) -> impl IntoResponse {
let data = data.read().await;
diff --git a/crates/app/src/routes/pages/mod.rs b/crates/app/src/routes/pages/mod.rs
index 3c7d58d..33b881a 100644
--- a/crates/app/src/routes/pages/mod.rs
+++ b/crates/app/src/routes/pages/mod.rs
@@ -18,14 +18,20 @@ pub fn routes() -> Router {
// misc
.route("/", get(misc::index_request))
.route("/notifs", get(misc::notifications_request))
+ .fallback_service(get(misc::not_found))
// auth
.route("/auth/register", get(auth::register_request))
.route("/auth/login", get(auth::login_request))
// profile
+ .route("/settings", get(profile::settings_request))
.route("/user/{username}", get(profile::posts_request))
// communities
.route("/communities", get(communities::list_request))
.route("/community/{title}", get(communities::feed_request))
+ .route(
+ "/community/{title}/manage",
+ get(communities::settings_request),
+ )
.route("/post/{id}", get(communities::post_request))
}
diff --git a/crates/app/src/routes/pages/profile.rs b/crates/app/src/routes/pages/profile.rs
index 9713f79..2061d7e 100644
--- a/crates/app/src/routes/pages/profile.rs
+++ b/crates/app/src/routes/pages/profile.rs
@@ -9,6 +9,48 @@ use axum_extra::extract::CookieJar;
use tera::Context;
use tetratto_core::model::{Error, auth::User, communities::Community};
+/// `/settings`
+pub async fn settings_request(
+ jar: CookieJar,
+ Extension(data): Extension,
+) -> impl IntoResponse {
+ let data = data.read().await;
+ let user = match get_user_from_token!(jar, data.0) {
+ Some(ua) => ua,
+ None => {
+ return Err(Html(
+ render_error(Error::NotAllowed, &jar, &data, &None).await,
+ ));
+ }
+ };
+
+ let settings = user.settings.clone();
+ let tokens = user.tokens.clone();
+
+ let lang = get_lang!(jar, data.0);
+ let mut context = initial_context(&data.0.0, lang, &Some(user)).await;
+
+ context.insert(
+ "user_settings_serde",
+ &serde_json::to_string(&settings)
+ .unwrap()
+ .replace("\"", "\\\""),
+ );
+ context.insert(
+ "user_tokens_serde",
+ &serde_json::to_string(&tokens)
+ .unwrap()
+ .replace("\"", "\\\""),
+ );
+
+ // return
+ Ok(Html(
+ data.1
+ .render("profile/settings.html", &mut context)
+ .unwrap(),
+ ))
+}
+
pub fn profile_context(
context: &mut Context,
profile: &User,
diff --git a/crates/core/src/database/auth.rs b/crates/core/src/database/auth.rs
index b3e7df9..bd86f0b 100644
--- a/crates/core/src/database/auth.rs
+++ b/crates/core/src/database/auth.rs
@@ -6,7 +6,7 @@ use crate::model::{
permissions::FinePermission,
};
use crate::{auto_method, execute, get, query_row};
-use tetratto_shared::hash::hash_salted;
+use tetratto_shared::hash::{hash_salted, salt};
#[cfg(feature = "sqlite")]
use rusqlite::Row;
@@ -86,7 +86,7 @@ impl DataManager {
// make sure username isn't taken
if self.get_user_by_username(&data.username).await.is_ok() {
- return Err(Error::MiscError("Username in use".to_string()));
+ return Err(Error::UsernameInUse);
}
// ...
@@ -130,7 +130,7 @@ impl DataManager {
pub async fn delete_user(&self, id: usize, password: &str, force: bool) -> Result<()> {
let user = self.get_user_by_id(id).await?;
- if (hash_salted(password.to_string(), user.salt) != user.password) && !force {
+ if (hash_salted(password.to_string(), user.salt.clone()) != user.password) && !force {
return Err(Error::IncorrectPassword);
}
@@ -145,8 +145,7 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
- self.2.remove(format!("atto.user:{}", id)).await;
- self.2.remove(format!("atto.user:{}", user.username)).await;
+ self.cache_clear_user(&user).await;
Ok(())
}
@@ -166,7 +165,8 @@ impl DataManager {
"UPDATE users SET is_verified = $1 WHERE id = $2",
&[
&(if x { 1 } else { 0 }).to_string().as_str(),
- &serde_json::to_string(&x).unwrap().as_str()
+ &serde_json::to_string(&x).unwrap().as_str(),
+ &id.to_string().as_str()
]
);
@@ -174,20 +174,86 @@ impl DataManager {
return Err(Error::DatabaseError(e.to_string()));
}
- self.2.remove(format!("atto.user:{}", id)).await;
+ self.cache_clear_user(&user).await;
Ok(())
}
- auto_method!(update_user_tokens(Vec) -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
- auto_method!(update_user_settings(UserSettings) -> "UPDATE users SET settings = $1 WHERE id = $2" --serde --cache-key-tmpl="atto.user:{}");
+ pub async fn update_user_password(
+ &self,
+ id: usize,
+ from: String,
+ to: String,
+ user: User,
+ force: bool,
+ ) -> Result<()> {
+ // verify password
+ if (hash_salted(from.clone(), user.salt.clone()) != user.password) && !force {
+ return Err(Error::MiscError("Password does not match".to_string()));
+ }
- auto_method!(incr_user_notifications() -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --incr);
- auto_method!(decr_user_notifications() -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --decr);
+ // ...
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
- auto_method!(incr_user_follower_count() -> "UPDATE users SET follower_count = follower_count + 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --incr);
- auto_method!(decr_user_follower_count() -> "UPDATE users SET follower_count = follower_count - 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --decr);
+ let new_salt = salt();
+ let new_password = hash_salted(to, new_salt.clone());
+ let res = execute!(
+ &conn,
+ "UPDATE users SET password = $1, salt = $2 WHERE id = $3",
+ &[
+ &new_password.as_str(),
+ &new_salt.as_str(),
+ &id.to_string().as_str()
+ ]
+ );
- auto_method!(incr_user_following_count() -> "UPDATE users SET following_count = following_count + 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --incr);
- auto_method!(decr_user_following_count() -> "UPDATE users SET following_count = following_count - 1 WHERE id = $1" --cache-key-tmpl="atto.user:{}" --decr);
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ self.cache_clear_user(&user).await;
+
+ Ok(())
+ }
+
+ pub async fn update_user_username(&self, id: usize, to: String, user: User) -> Result<()> {
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ let res = execute!(
+ &conn,
+ "UPDATE users SET username = $1 WHERE id = $3",
+ &[&to.as_str(), &id.to_string().as_str()]
+ );
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ self.cache_clear_user(&user).await;
+
+ Ok(())
+ }
+
+ pub async fn cache_clear_user(&self, user: &User) {
+ self.2.remove(format!("atto.user:{}", user.id)).await;
+ self.2.remove(format!("atto.user:{}", user.username)).await;
+ }
+
+ auto_method!(update_user_tokens(Vec)@get_user_by_id -> "UPDATE users SET tokens = $1 WHERE id = $2" --serde --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!(incr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
+ auto_method!(decr_user_notifications()@get_user_by_id -> "UPDATE users SET notification_count = notification_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr);
+
+ auto_method!(incr_user_follower_count()@get_user_by_id -> "UPDATE users SET follower_count = follower_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
+ auto_method!(decr_user_follower_count()@get_user_by_id -> "UPDATE users SET follower_count = follower_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr);
+
+ auto_method!(incr_user_following_count()@get_user_by_id -> "UPDATE users SET following_count = following_count + 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --incr);
+ auto_method!(decr_user_following_count()@get_user_by_id -> "UPDATE users SET following_count = following_count - 1 WHERE id = $1" --cache-key-tmpl=cache_clear_user --decr);
}
diff --git a/crates/core/src/database/common.rs b/crates/core/src/database/common.rs
index bbe0740..1e35cbb 100644
--- a/crates/core/src/database/common.rs
+++ b/crates/core/src/database/common.rs
@@ -465,6 +465,31 @@ macro_rules! auto_method {
}
};
+ ($name:ident($x:ty)@$select_fn:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => {
+ pub async fn $name(&self, id: usize, x: $x) -> Result<()> {
+ let y = self.$select_fn(id).await?;
+
+ let conn = match self.connect().await {
+ Ok(c) => c,
+ Err(e) => return Err(Error::DatabaseConnection(e.to_string())),
+ };
+
+ let res = execute!(
+ &conn,
+ $query,
+ &[&serde_json::to_string(&x).unwrap(), &id.to_string()]
+ );
+
+ if let Err(e) = res {
+ return Err(Error::DatabaseError(e.to_string()));
+ }
+
+ self.$cache_key_tmpl(&y).await;
+
+ Ok(())
+ }
+ };
+
($name:ident($x:ty)@$select_fn:ident:$permission:ident -> $query:literal --serde --cache-key-tmpl=$cache_key_tmpl:ident) => {
pub async fn $name(&self, id: usize, user: User, x: $x) -> Result<()> {
let y = self.$select_fn(id).await?;
diff --git a/crates/core/src/model/auth.rs b/crates/core/src/model/auth.rs
index 831e774..caadf19 100644
--- a/crates/core/src/model/auth.rs
+++ b/crates/core/src/model/auth.rs
@@ -25,7 +25,7 @@ pub struct User {
pub following_count: usize,
}
-#[derive(Debug, Serialize, Deserialize)]
+#[derive(Clone, Debug, Serialize, Deserialize)]
pub struct UserSettings {
#[serde(default)]
pub display_name: String,
diff --git a/crates/core/src/model/mod.rs b/crates/core/src/model/mod.rs
index afa77b8..06277b3 100644
--- a/crates/core/src/model/mod.rs
+++ b/crates/core/src/model/mod.rs
@@ -29,6 +29,7 @@ pub enum Error {
AlreadyAuthenticated,
DataTooLong(String),
DataTooShort(String),
+ UsernameInUse,
Unknown,
}
@@ -46,6 +47,7 @@ impl ToString for Error {
Self::AlreadyAuthenticated => "Already authenticated".to_string(),
Self::DataTooLong(name) => format!("Given {name} is too long!"),
Self::DataTooShort(name) => format!("Given {name} is too short!"),
+ Self::UsernameInUse => "Username in use".to_string(),
_ => format!("An unknown error as occurred: ({:?})", self),
}
}