aboutsummaryrefslogtreecommitdiff
path: root/server/src/ui/account
diff options
context:
space:
mode:
authormetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
committermetamuffin <metamuffin@disroot.org>2025-04-27 19:25:11 +0200
commit11a585b3dbe620dcc8772e713b22f1d9ba80d598 (patch)
tree44f8d97137412aefc79a2425a489c34fa3e5f6c5 /server/src/ui/account
parentd871aa7c5bba49ff55170b5d2dac9cd440ae7170 (diff)
downloadjellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.bz2
jellything-11a585b3dbe620dcc8772e713b22f1d9ba80d598.tar.zst
move files around
Diffstat (limited to 'server/src/ui/account')
-rw-r--r--server/src/ui/account/mod.rs256
-rw-r--r--server/src/ui/account/settings.rs185
2 files changed, 441 insertions, 0 deletions
diff --git a/server/src/ui/account/mod.rs b/server/src/ui/account/mod.rs
new file mode 100644
index 0000000..312b40c
--- /dev/null
+++ b/server/src/ui/account/mod.rs
@@ -0,0 +1,256 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+pub mod settings;
+
+use super::{
+ error::MyError,
+ layout::{trs, LayoutPage},
+};
+use crate::{
+ database::Database,
+ locale::AcceptLanguage,
+ logic::session::{self, Session},
+ ui::{error::MyResult, home::rocket_uri_macro_r_home, layout::DynLayoutPage},
+ uri,
+};
+use anyhow::anyhow;
+use argon2::{password_hash::Salt, Argon2, PasswordHasher};
+use chrono::Duration;
+use jellybase::{locale::tr, CONF};
+use jellycommon::user::{User, UserPermission};
+use rocket::{
+ form::{Contextual, Form},
+ get,
+ http::{Cookie, CookieJar},
+ post,
+ response::Redirect,
+ FromForm, State,
+};
+use serde::{Deserialize, Serialize};
+use std::collections::HashSet;
+
+#[derive(FromForm)]
+pub struct RegisterForm {
+ #[field(validate = len(8..128))]
+ pub invitation: String,
+ #[field(validate = len(4..32))]
+ pub username: String,
+ #[field(validate = len(4..64))]
+ pub password: String,
+}
+
+#[get("/account/register")]
+pub async fn r_account_register(lang: AcceptLanguage) -> DynLayoutPage<'static> {
+ let AcceptLanguage(lang) = lang;
+ LayoutPage {
+ title: tr(lang, "account.register").to_string(),
+ content: markup::new! {
+ form.account[method="POST", action=""] {
+ h1 { @trs(&lang, "account.register") }
+
+ label[for="inp-invitation"] { @trs(&lang, "account.register.invitation") }
+ input[type="text", id="inp-invitation", name="invitation"]; br;
+
+ label[for="inp-username"] { @trs(&lang, "account.username") }
+ input[type="text", id="inp-username", name="username"]; br;
+ label[for="inp-password"] { @trs(&lang, "account.password") }
+ input[type="password", id="inp-password", name="password"]; br;
+
+ input[type="submit", value=&*tr(lang, "account.register.submit")];
+
+ p { @trs(&lang, "account.register.login") " " a[href=uri!(r_account_login())] { @trs(&lang, "account.register.login_here") } }
+ }
+ },
+ ..Default::default()
+ }
+}
+
+#[derive(FromForm, Serialize, Deserialize)]
+pub struct LoginForm {
+ #[field(validate = len(4..32))]
+ pub username: String,
+ #[field(validate = len(..64))]
+ pub password: String,
+ #[field(default = 604800)] // one week
+ pub expire: u64,
+}
+
+#[get("/account/login")]
+pub fn r_account_login(sess: Option<Session>, lang: AcceptLanguage) -> DynLayoutPage<'static> {
+ let AcceptLanguage(lang) = lang;
+ let logged_in = sess.is_some();
+ let title = tr(
+ lang,
+ if logged_in {
+ "account.login.switch"
+ } else {
+ "account.login"
+ },
+ );
+ LayoutPage {
+ title: title.to_string(),
+ content: markup::new! {
+ form.account[method="POST", action=""] {
+ h1 { @title.to_string() }
+
+ label[for="inp-username"] { @trs(&lang, "account.username") }
+ input[type="text", id="inp-username", name="username"]; br;
+ label[for="inp-password"] { @trs(&lang, "account.password") }
+ input[type="password", id="inp-password", name="password"]; br;
+
+ input[type="submit", value=&*tr(lang, if logged_in { "account.login.submit.switch" } else { "account.login.submit" })];
+
+ @if logged_in {
+ p { @trs(&lang, "account.login.register.switch") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } }
+ } else {
+ p { @trs(&lang, "account.login.cookie_note") }
+ p { @trs(&lang, "account.login.register") " " a[href=uri!(r_account_register())] { @trs(&lang, "account.login.register_here") } }
+ }
+ }
+ },
+ ..Default::default()
+ }
+}
+
+#[get("/account/logout")]
+pub fn r_account_logout(lang: AcceptLanguage) -> DynLayoutPage<'static> {
+ let AcceptLanguage(lang) = lang;
+ LayoutPage {
+ title: tr(lang, "account.logout").to_string(),
+ content: markup::new! {
+ form.account[method="POST", action=""] {
+ h1 { @trs(&lang, "account.logout") }
+ input[type="submit", value=&*tr(lang, "account.logout.submit")];
+ }
+ },
+ ..Default::default()
+ }
+}
+
+#[post("/account/register", data = "<form>")]
+pub fn r_account_register_post<'a>(
+ database: &'a State<Database>,
+ _sess: Option<Session>,
+ form: Form<Contextual<'a, RegisterForm>>,
+ lang: AcceptLanguage,
+) -> MyResult<DynLayoutPage<'a>> {
+ let AcceptLanguage(lang) = lang;
+ let logged_in = _sess.is_some();
+ let form = match &form.value {
+ Some(v) => v,
+ None => return Err(format_form_error(form)),
+ };
+
+ database.register_user(
+ &form.invitation,
+ &form.username,
+ User {
+ display_name: form.username.clone(),
+ name: form.username.clone(),
+ password: hash_password(&form.username, &form.password),
+ ..Default::default()
+ },
+ )?;
+
+ Ok(LayoutPage {
+ title: tr(lang, "account.register.success.title").to_string(),
+ content: markup::new! {
+ h1 { @trs(&lang, if logged_in {
+ "account.register.success.switch"
+ } else {
+ "account.register.success"
+ })}
+ },
+ ..Default::default()
+ })
+}
+
+#[post("/account/login", data = "<form>")]
+pub fn r_account_login_post(
+ database: &State<Database>,
+ jar: &CookieJar,
+ form: Form<Contextual<LoginForm>>,
+) -> MyResult<Redirect> {
+ let form = match &form.value {
+ Some(v) => v,
+ None => return Err(format_form_error(form)),
+ };
+ jar.add(
+ Cookie::build((
+ "session",
+ login_logic(database, &form.username, &form.password, None, None)?,
+ ))
+ .permanent()
+ .build(),
+ );
+
+ Ok(Redirect::found(rocket::uri!(r_home())))
+}
+
+#[post("/account/logout")]
+pub fn r_account_logout_post(jar: &CookieJar) -> MyResult<Redirect> {
+ jar.remove_private(Cookie::build("session"));
+ Ok(Redirect::found(rocket::uri!(r_home())))
+}
+
+pub fn login_logic(
+ database: &Database,
+ username: &str,
+ password: &str,
+ expire: Option<i64>,
+ drop_permissions: Option<HashSet<UserPermission>>,
+) -> MyResult<String> {
+ // hashing the password regardless if the accounts exists to better resist timing attacks
+ let password = hash_password(username, password);
+
+ let mut user = database
+ .get_user(username)?
+ .ok_or(anyhow!("invalid password"))?;
+
+ if user.password != password {
+ Err(anyhow!("invalid password"))?
+ }
+
+ if let Some(ep) = drop_permissions {
+ // remove all grant perms that are in `ep`
+ user.permissions
+ .0
+ .retain(|p, val| if *val { !ep.contains(p) } else { true })
+ }
+
+ Ok(session::create(
+ user.name,
+ user.permissions,
+ Duration::days(CONF.login_expire.min(expire.unwrap_or(i64::MAX))),
+ ))
+}
+
+pub fn format_form_error<T>(form: Form<Contextual<T>>) -> MyError {
+ let mut k = String::from("form validation failed:");
+ for e in form.context.errors() {
+ k += &format!(
+ "\n\t{}: {e}",
+ e.name
+ .as_ref()
+ .map(|e| e.to_string())
+ .unwrap_or("<unknown>".to_string())
+ )
+ }
+ MyError(anyhow!(k))
+}
+
+pub fn hash_password(username: &str, password: &str) -> Vec<u8> {
+ Argon2::default()
+ .hash_password(
+ format!("{username}\0{password}").as_bytes(),
+ <&str as TryInto<Salt>>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(),
+ )
+ .unwrap()
+ .hash
+ .unwrap()
+ .as_bytes()
+ .to_vec()
+}
diff --git a/server/src/ui/account/settings.rs b/server/src/ui/account/settings.rs
new file mode 100644
index 0000000..4047e4f
--- /dev/null
+++ b/server/src/ui/account/settings.rs
@@ -0,0 +1,185 @@
+/*
+ This file is part of jellything (https://codeberg.org/metamuffin/jellything)
+ which is licensed under the GNU Affero General Public License (version 3); see /COPYING.
+ Copyright (C) 2025 metamuffin <metamuffin.org>
+*/
+use super::{format_form_error, hash_password};
+use crate::{
+ database::Database,
+ locale::AcceptLanguage,
+ ui::{
+ account::{rocket_uri_macro_r_account_login, session::Session},
+ error::MyResult,
+ layout::{trs, DynLayoutPage, LayoutPage},
+ },
+ uri,
+};
+use jellybase::{
+ locale::{tr, Language},
+ permission::PermissionSetExt,
+};
+use jellycommon::user::{PlayerKind, Theme, UserPermission};
+use markup::{Render, RenderAttributeValue};
+use rocket::{
+ form::{self, validate::len, Contextual, Form},
+ get,
+ http::uri::fmt::{Query, UriDisplay},
+ post, FromForm, State,
+};
+use std::ops::Range;
+
+#[derive(FromForm)]
+pub struct SettingsForm {
+ #[field(validate = option_len(4..64))]
+ password: Option<String>,
+ #[field(validate = option_len(4..32))]
+ display_name: Option<String>,
+ theme: Option<Theme>,
+ player_preference: Option<PlayerKind>,
+ native_secret: Option<String>,
+}
+
+fn option_len<'v>(value: &Option<String>, range: Range<usize>) -> form::Result<'v, ()> {
+ value.as_ref().map(|v| len(v, range)).unwrap_or(Ok(()))
+}
+
+fn settings_page(
+ session: Session,
+ flash: Option<MyResult<String>>,
+ lang: Language,
+) -> DynLayoutPage<'static> {
+ LayoutPage {
+ title: "Settings".to_string(),
+ class: Some("settings"),
+ content: markup::new! {
+ h1 { "Settings" }
+ @if let Some(flash) = &flash {
+ @match flash {
+ Ok(mesg) => { section.message { p.success { @mesg } } }
+ Err(err) => { section.message { p.error { @format!("{err}") } } }
+ }
+ }
+ h2 { @trs(&lang, "account") }
+ a.switch_account[href=uri!(r_account_login())] { "Switch Account" }
+ form[method="POST", action=uri!(r_account_settings_post())] {
+ label[for="username"] { @trs(&lang, "account.username") }
+ input[type="text", id="username", disabled, value=&session.user.name];
+ input[type="submit", disabled, value=&*tr(lang, "settings.immutable")];
+ }
+ form[method="POST", action=uri!(r_account_settings_post())] {
+ label[for="display_name"] { @trs(&lang, "account.display_name") }
+ input[type="text", id="display_name", name="display_name", value=&session.user.display_name];
+ input[type="submit", value=&*tr(lang, "settings.update")];
+ }
+ form[method="POST", action=uri!(r_account_settings_post())] {
+ label[for="password"] { @trs(&lang, "account.password") }
+ input[type="password", id="password", name="password"];
+ input[type="submit", value=&*tr(lang, "settings.update")];
+ }
+ h2 { @trs(&lang, "settings.appearance") }
+ form[method="POST", action=uri!(r_account_settings_post())] {
+ fieldset {
+ legend { @trs(&lang, "settings.appearance.theme") }
+ @for (t, tlabel) in Theme::LIST {
+ label { input[type="radio", name="theme", value=A(*t), checked=session.user.theme==*t]; @tlabel } br;
+ }
+ }
+ input[type="submit", value=&*tr(lang, "settings.apply")];
+ }
+ form[method="POST", action=uri!(r_account_settings_post())] {
+ fieldset {
+ legend { @trs(&lang, "settings.player_preference") }
+ @for (t, tlabel) in PlayerKind::LIST {
+ label { input[type="radio", name="player_preference", value=A(*t), checked=session.user.player_preference==*t]; @tlabel } br;
+ }
+ }
+ input[type="submit", value=&*tr(lang, "settings.apply")];
+ }
+ form[method="POST", action=uri!(r_account_settings_post())] {
+ label[for="native_secret"] { "Native Secret" }
+ input[type="password", id="native_secret", name="native_secret"];
+ input[type="submit", value=&*tr(lang, "settings.update")];
+ p { "The secret can be found in " code{"$XDG_CONFIG_HOME/jellynative_secret"} " or by clicking " a.button[href="jellynative://show-secret-v1"] { "Show Secret" } "." }
+ }
+ },
+ }
+}
+
+struct A<T>(pub T);
+impl<T: UriDisplay<Query>> RenderAttributeValue for A<T> {}
+impl<T: UriDisplay<Query>> Render for A<T> {
+ fn render(&self, writer: &mut impl std::fmt::Write) -> std::fmt::Result {
+ writer.write_fmt(format_args!("{}", &self.0 as &dyn UriDisplay<Query>))
+ }
+}
+
+#[get("/account/settings")]
+pub fn r_account_settings(session: Session, lang: AcceptLanguage) -> DynLayoutPage<'static> {
+ let AcceptLanguage(lang) = lang;
+ settings_page(session, None, lang)
+}
+
+#[post("/account/settings", data = "<form>")]
+pub fn r_account_settings_post(
+ session: Session,
+ database: &State<Database>,
+ form: Form<Contextual<SettingsForm>>,
+ lang: AcceptLanguage,
+) -> MyResult<DynLayoutPage<'static>> {
+ let AcceptLanguage(lang) = lang;
+ session
+ .user
+ .permissions
+ .assert(&UserPermission::ManageSelf)?;
+
+ let form = match &form.value {
+ Some(v) => v,
+ None => {
+ return Ok(settings_page(
+ session,
+ Some(Err(format_form_error(form))),
+ lang,
+ ))
+ }
+ };
+
+ let mut out = String::new();
+
+ database.update_user(&session.user.name, |user| {
+ if let Some(password) = &form.password {
+ user.password = hash_password(&session.user.name, password);
+ out += &*tr(lang, "settings.account.password.changed");
+ out += "\n";
+ }
+ if let Some(display_name) = &form.display_name {
+ user.display_name = display_name.clone();
+ out += &*tr(lang, "settings.account.display_name.changed");
+ out += "\n";
+ }
+ if let Some(theme) = form.theme {
+ user.theme = theme;
+ out += &*tr(lang, "settings.account.theme.changed");
+ out += "\n";
+ }
+ if let Some(player_preference) = form.player_preference {
+ user.player_preference = player_preference;
+ out += &*tr(lang, "settings.player_preference.changed");
+ out += "\n";
+ }
+ if let Some(native_secret) = &form.native_secret {
+ user.native_secret = native_secret.to_owned();
+ out += "Native secret updated.\n";
+ }
+ Ok(())
+ })?;
+
+ Ok(settings_page(
+ session, // using the old session here, results in outdated theme being displayed
+ Some(Ok(if out.is_empty() {
+ tr(lang, "settings.no_change").to_string()
+ } else {
+ out
+ })),
+ lang,
+ ))
+}