diff options
Diffstat (limited to 'server/src/ui/account')
-rw-r--r-- | server/src/ui/account/mod.rs | 256 | ||||
-rw-r--r-- | server/src/ui/account/settings.rs | 185 |
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, + )) +} |