/* 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 */ pub mod settings; use super::error::MyError; use crate::{ database::Database, locale::AcceptLanguage, logic::session::{self, Session}, ui::{error::MyResult, home::rocket_uri_macro_r_home}, uri, }; use anyhow::anyhow; use argon2::{password_hash::Salt, Argon2, PasswordHasher}; use chrono::Duration; 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, 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 = "
")] pub fn r_account_register_post<'a>( database: &'a State, _sess: Option, form: Form>, lang: AcceptLanguage, ) -> MyResult> { 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 = "")] pub fn r_account_login_post( database: &State, jar: &CookieJar, form: Form>, ) -> MyResult { 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 { 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, drop_permissions: Option>, ) -> MyResult { // 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(form: Form>) -> 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("".to_string()) ) } MyError(anyhow!(k)) } pub fn hash_password(username: &str, password: &str) -> Vec { Argon2::default() .hash_password( format!("{username}\0{password}").as_bytes(), <&str as TryInto>::try_into("IYMa13osbNeLJKnQ1T8LlA").unwrap(), ) .unwrap() .hash .unwrap() .as_bytes() .to_vec() }