From e998a38e49e93fc4c42fac6fbd9016c058c89093 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Mateusz=20Gruszczy=C5=84ski?= Date: Wed, 22 Jul 2026 23:04:29 +0200 Subject: [PATCH] mails --- Cargo.toml | 2 +- src/app.rs | 2 +- src/auth.rs | 396 ++++++++++++++++++++++++++++++++++++++----- src/database.rs | 8 +- src/state.rs | 4 +- src/websocket.rs | 27 ++- static/js/session.js | 23 ++- 7 files changed, 401 insertions(+), 61 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 941b938..eba2890 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "rustpad" -version = "0.0.6" +version = "0.0.7" edition = "2024" rust-version = "1.94" description = "Collaborative Markdown notepad built with Axum, WebSockets and SQLite, PostgreSQL and MySQL" diff --git a/src/app.rs b/src/app.rs index c12a1f0..fbebb97 100644 --- a/src/app.rs +++ b/src/app.rs @@ -94,7 +94,7 @@ pub fn router(state: SharedState, static_dir: &str, upload_max_size_bytes: usize ServiceBuilder::new() .layer(SetResponseHeaderLayer::overriding( header::CACHE_CONTROL, - HeaderValue::from_static("private, must-revalidate"), + HeaderValue::from_static("public, max-age=600"), )) .service(ServeDir::new(static_dir).not_found_service(asset_not_found)), ) diff --git a/src/auth.rs b/src/auth.rs index 7a3954e..0be2aa4 100644 --- a/src/auth.rs +++ b/src/auth.rs @@ -1,7 +1,11 @@ use argon2::{password_hash::{PasswordHash, PasswordHasher, PasswordVerifier, SaltString}, Argon2}; use axum::{extract::State, http::{HeaderMap, StatusCode}, Json}; use chrono::{Duration, Utc}; -use lettre::{message::Mailbox, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, transport::smtp::authentication::Credentials}; +use lettre::{ + message::{header::ContentType, Mailbox, MultiPart, SinglePart}, + transport::smtp::authentication::Credentials, + AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, +}; use rand_core::{OsRng, RngCore}; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; @@ -15,20 +19,36 @@ const MAX_PASSWORD: usize = 128; const MAX_NICKNAME: usize = 40; #[derive(Debug, Clone, FromRow)] -pub struct User { pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option } +pub struct User { + pub id: i64, pub nickname: String, pub email: String, pub password_hash: String, pub confirmed_at: Option +} -#[derive(Deserialize)] pub struct IdentityRequest { nickname: String, #[serde(default)] session_token: Option } -#[derive(Deserialize)] pub struct RegisterRequest { nickname: String, email: String, password: String } +#[derive(Deserialize)] pub struct IdentityRequest { + nickname: String, #[serde(default)] session_token: Option +} +#[derive(Deserialize)] pub struct RegisterRequest { + nickname: String, email: String, password: String +} #[derive(Deserialize)] pub struct LoginRequest { email: String, password: String } #[derive(Deserialize)] pub struct ConfirmAccountRequest { token: String } #[derive(Deserialize)] pub struct ResetRequest { email: String } #[derive(Deserialize)] pub struct ResetConfirmRequest { token: String, password: String } -#[derive(Deserialize)] pub struct ResourceActionRequest { kind: String, slug: String, #[serde(default)] password: Option } -#[derive(Serialize, FromRow)] pub struct ResourceItem { slug: String, title: String, protected: i64, updated_at: String } -#[derive(Serialize)] pub struct ResourceList { workspaces: Vec, pads: Vec } -#[derive(Serialize)] pub struct SessionResponse { token: String, nickname: String, email: String, expires_at: String } +#[derive(Deserialize)] pub struct ResourceActionRequest { + kind: String, slug: String, #[serde(default)] password: Option +} +#[derive(Serialize, FromRow)] pub struct ResourceItem { + slug: String, title: String, protected: i64, updated_at: String +} +#[derive(Serialize)] pub struct ResourceList { + workspaces: Vec, pads: Vec +} +#[derive(Serialize)] pub struct SessionResponse { + token: String, nickname: String, email: String, expires_at: String +} #[derive(Serialize)] pub struct IdentityResponse { nickname: String, registered: bool } -#[derive(Serialize)] pub struct RegisterResponse { token: Option, nickname: String, email: String, expires_at: Option, confirmation_required: bool, message: String } +#[derive(Serialize)] pub struct RegisterResponse { + token: Option, nickname: String, email: String, expires_at: Option, confirmation_required: bool, message: String +} pub async fn identity(State(state): State, Json(req): Json) -> Result, AuthError> { let nickname = validate_nickname(&req.nickname)?; @@ -65,7 +85,7 @@ pub async fn register(State(state): State, Json(req): Json Result(queries::get(state.db.kind(), queries::AUTH_USER_BY_EMAIL)) .bind(normalize(email)).fetch_optional(state.db.pool()).await.map_err(AuthError::database) } -fn validate_nickname(v: &str) -> Result { let v=v.trim(); if v.is_empty() || v.chars().count()>MAX_NICKNAME { return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters.")); } if v.chars().any(|c| c.is_control()) { return Err(AuthError::bad_request("Nickname contains invalid characters.")); } Ok(v.into()) } -fn validate_email(v: &str) -> Result { let v=v.trim(); if v.len()>320 || !v.contains('@') || v.starts_with('@') || v.ends_with('@') { return Err(AuthError::bad_request("Enter a valid e-mail address.")); } Ok(v.into()) } -fn validate_password(v: &str) -> Result<(), AuthError> { if v.len()MAX_PASSWORD { Err(AuthError::bad_request("Password must contain 8 to 128 characters.")) } else { Ok(()) } } -fn normalize(v: &str)->String { v.trim().to_lowercase() } -fn hash_password(v:&str)->Result{let salt=SaltString::generate(&mut OsRng);Argon2::default().hash_password(v.as_bytes(),&salt).map(|h|h.to_string()).map_err(|_|AuthError::internal("Failed to secure the password."))} -fn verify_password(hash:&str,v:&str)->bool{PasswordHash::new(hash).ok().and_then(|h|Argon2::default().verify_password(v.as_bytes(),&h).ok()).is_some()} -fn random_token()->String{let mut bytes=[0u8;32];let mut rng=OsRng;rng.fill_bytes(&mut bytes);bytes.iter().map(|b|format!("{b:02x}")).collect()} -fn hash_token(v:&str)->String{format!("{:x}",Sha256::digest(v.as_bytes()))} -fn bearer(headers:&HeaderMap)->Option<&str>{headers.get("authorization")?.to_str().ok()?.strip_prefix("Bearer ")} -async fn send_registration_email(smtp: &SmtpConfig, user: &User, token: Option<&str>) -> Result<(), AuthError> { - let site = smtp.public_url.trim_end_matches('/'); - let (subject, body) = match token { - Some(token) => ("Confirm your RustPad account", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours:\n{}/?confirm_token={}\n", user.nickname, user.nickname, site, site, token)), - None => ("Your RustPad account has been created", format!("Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", user.nickname, user.nickname, site)), - }; - let message = Message::builder().from(smtp.from.parse::().map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::().map_err(|_| AuthError::internal("Invalid recipient address."))?).subject(subject).body(body).map_err(|_| AuthError::internal("Failed to build registration e-mail."))?; - send_message(smtp, message, "registration e-mail").await +fn validate_nickname(v: &str) -> Result { + let v=v.trim(); + if v.is_empty() || v.chars().count()>MAX_NICKNAME { + return Err(AuthError::bad_request("Nickname must contain 1 to 40 characters.")); + } + if v.chars().any(|c| c.is_control()) { + return Err(AuthError::bad_request("Nickname contains invalid characters.")); + } + Ok(v.into()) +} +fn validate_email(value: &str) -> Result { + let value = value.trim(); + + if value.len() > 320 + || !value.contains('@') + || value.starts_with('@') + || value.ends_with('@') + { + return Err(AuthError::bad_request("Enter a valid e-mail address.")); + } + + Ok(value.into()) } -async fn send_message(smtp: &SmtpConfig, message: Message, label: &str) -> Result<(), AuthError> { - let mut builder = if smtp.port == 465 { AsyncSmtpTransport::::relay(&smtp.host) } else { AsyncSmtpTransport::::starttls_relay(&smtp.host) } - .map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })?.port(smtp.port); - if !smtp.username.is_empty() { builder = builder.credentials(Credentials::new(smtp.username.clone(), smtp.password.clone())); } - builder.build().send(message).await.map_err(|error| { tracing::error!(error=%error, host=%smtp.host, port=smtp.port, message_type=label, "SMTP delivery failed"); AuthError::service_unavailable("The e-mail could not be sent. Check the SMTP configuration.") })?; +fn validate_password(value: &str) -> Result<(), AuthError> { + if value.len() < MIN_PASSWORD || value.len() > MAX_PASSWORD { + return Err(AuthError::bad_request( + "Password must contain 8 to 128 characters.", + )); + } + Ok(()) } -async fn send_reset(smtp:&SmtpConfig,user:&User,token:&str)->Result<(),AuthError>{ - let url=format!("{}/?reset_token={}",smtp.public_url.trim_end_matches('/'),token); - let message=Message::builder().from(smtp.from.parse::().map_err(|_|AuthError::internal("Invalid SMTP_FROM."))?).to(user.email.parse::().map_err(|_|AuthError::internal("Invalid recipient address."))?).subject("RustPad password reset").body(format!("Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.",user.nickname,url)).map_err(|_|AuthError::internal("Failed to build reset e-mail."))?; - // Port 465 uses implicit TLS. Standard submission ports (usually 587) - // require STARTTLS; using implicit TLS there causes an immediate SMTP failure. +fn normalize(value: &str) -> String { + value.trim().to_lowercase() +} + +fn hash_password(value: &str) -> Result { + let salt = SaltString::generate(&mut OsRng); + + Argon2::default() + .hash_password(value.as_bytes(), &salt) + .map(|hash| hash.to_string()) + .map_err(|_| AuthError::internal("Failed to secure the password.")) +} + +fn verify_password(hash: &str, value: &str) -> bool { + PasswordHash::new(hash) + .ok() + .and_then(|hash| { + Argon2::default() + .verify_password(value.as_bytes(), &hash) + .ok() + }) + .is_some() +} + +fn random_token() -> String { + random_hex_token::<32>() +} + +fn random_confirmation_token() -> String { + random_hex_token::<32>() +} + +fn random_hex_token() -> String { + let mut bytes = [0_u8; N]; + let mut rng = OsRng; + rng.fill_bytes(&mut bytes); + + bytes.iter().map(|byte| format!("{byte:02x}")).collect() +} + +fn hash_token(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes())) +} + +fn bearer(headers: &HeaderMap) -> Option<&str> { + headers + .get("authorization")? + .to_str() + .ok()? + .strip_prefix("Bearer ") +} + +async fn send_registration_email( + smtp: &SmtpConfig, + user: &User, + token: Option<&str>, +) -> Result<(), AuthError> { + let site = smtp.public_url.trim_end_matches('/'); + let sender = smtp + .from + .parse::() + .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; + let recipient = user + .email + .parse::() + .map_err(|_| AuthError::internal("Invalid recipient address."))?; + + let builder = Message::builder().from(sender).to(recipient); + + let message = match token { + Some(token) => { + let confirmation_url = format!("{site}/?confirm_token={token}"); + let subject = "Confirm your RustPad account"; + let text_body = format!( + "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n\nConfirm the account within 24 hours by opening this link:\n{}\n", + user.nickname, user.nickname, site, confirmation_url + ); + let html_body = format!( + r#" + + +
+

Confirm your RustPad account

+

Hello {},

+

Your RustPad account has been created.

+

Nickname: {}
Site: {}

+

Confirm the account within 24 hours:

+

Confirm account

+

If the button does not work, open this address:

+

{}

+
+ +"#, + user.nickname, + user.nickname, + site, + confirmation_url, + confirmation_url, + confirmation_url + ); + + builder + .subject(subject) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text_body), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body), + ), + ) + .map_err(|_| AuthError::internal("Failed to build registration e-mail."))? + } + None => { + let subject = "Your RustPad account has been created"; + let text_body = format!( + "Hello {},\n\nYour RustPad account has been created.\nNickname: {}\nSite: {}\n", + user.nickname, user.nickname, site + ); + let html_body = format!( + r#" + + +
+

Your RustPad account is ready

+

Hello {},

+

Your RustPad account has been created.

+

Nickname: {}

+

Open RustPad

+
+ +"#, + user.nickname, user.nickname, site + ); + + builder + .subject(subject) + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text_body), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body), + ), + ) + .map_err(|_| AuthError::internal("Failed to build registration e-mail."))? + } + }; + + send_message(smtp, message, "registration e-mail").await +} + +async fn send_message( + smtp: &SmtpConfig, + message: Message, + label: &str, +) -> Result<(), AuthError> { let mut builder = if smtp.port == 465 { AsyncSmtpTransport::::relay(&smtp.host) } else { AsyncSmtpTransport::::starttls_relay(&smtp.host) } .map_err(|error| { - tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "invalid SMTP configuration"); + tracing::error!(error = %error, host = %smtp.host, port = smtp.port, "invalid SMTP configuration"); AuthError::internal("Invalid SMTP configuration.") })? .port(smtp.port); - if !smtp.username.is_empty() { builder=builder.credentials(Credentials::new(smtp.username.clone(),smtp.password.clone())); } - let mailer=builder.build(); - mailer.send(message).await.map_err(|error| { - tracing::error!(error=%error, host=%smtp.host, port=smtp.port, "password reset e-mail failed"); - AuthError::service_unavailable("The reset e-mail could not be sent. Check the SMTP configuration.") + + if !smtp.username.is_empty() { + builder = builder.credentials(Credentials::new( + smtp.username.clone(), + smtp.password.clone(), + )); + } + + builder.build().send(message).await.map_err(|error| { + tracing::error!(error = %error, host = %smtp.host, port = smtp.port, message_type = label, "SMTP delivery failed"); + AuthError::service_unavailable( + "The e-mail could not be sent. Check the SMTP configuration.", + ) })?; + Ok(()) } -pub struct AuthError { status: StatusCode, pub message: String } -impl AuthError { fn bad_request(m:&str)->Self{Self{status:StatusCode::BAD_REQUEST,message:m.into()}} fn unauthorized(m:&str)->Self{Self{status:StatusCode::UNAUTHORIZED,message:m.into()}} fn forbidden(m:&str)->Self{Self{status:StatusCode::FORBIDDEN,message:m.into()}} fn conflict(m:&str)->Self{Self{status:StatusCode::CONFLICT,message:m.into()}} fn internal(m:&str)->Self{Self{status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into()}} fn service_unavailable(m:&str)->Self{Self{status:StatusCode::SERVICE_UNAVAILABLE,message:m.into()}} fn database(e:sqlx::Error)->Self{tracing::error!(error=%e,"authentication database error");Self::internal("Database error.")} } +async fn send_reset( + smtp: &SmtpConfig, + user: &User, + token: &str, +) -> Result<(), AuthError> { + let site = smtp.public_url.trim_end_matches('/'); + let reset_url = format!("{site}/?reset_token={token}"); + let sender = smtp + .from + .parse::() + .map_err(|_| AuthError::internal("Invalid SMTP_FROM."))?; + let recipient = user + .email + .parse::() + .map_err(|_| AuthError::internal("Invalid recipient address."))?; -fn email_domain(email: &str) -> &str { email.rsplit_once('@').map(|(_, domain)| domain).unwrap_or("invalid") } -impl axum::response::IntoResponse for AuthError { fn into_response(self)->axum::response::Response{(self.status,Json(serde_json::json!({"error":self.message}))).into_response()} } + let text_body = format!( + "Hello {},\n\nUse this link within 30 minutes to set a new password:\n{}\n\nIf you did not request this, ignore this message.", + user.nickname, reset_url + ); + let html_body = format!( + r#" + + +
+

Reset your RustPad password

+

Hello {},

+

Use the button below within 30 minutes to set a new password.

+

Reset password

+

If the button does not work, open this address:

+

{}

+

If you did not request a password reset, ignore this message.

+
+ +"#, + user.nickname, reset_url, reset_url, reset_url + ); + + let message = Message::builder() + .from(sender) + .to(recipient) + .subject("RustPad password reset") + .multipart( + MultiPart::alternative() + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_PLAIN) + .body(text_body), + ) + .singlepart( + SinglePart::builder() + .header(ContentType::TEXT_HTML) + .body(html_body), + ), + ) + .map_err(|_| AuthError::internal("Failed to build reset e-mail."))?; + + send_message(smtp, message, "password reset e-mail").await +} + +pub struct AuthError { status: StatusCode, pub message: String } +impl AuthError { + fn bad_request(m:&str)->Self { + Self { + status:StatusCode::BAD_REQUEST,message:m.into() + } + } + fn unauthorized(m:&str)->Self { + Self { + status:StatusCode::UNAUTHORIZED,message:m.into() + } + } + fn forbidden(m:&str)->Self { + Self { + status:StatusCode::FORBIDDEN,message:m.into() + } + } + fn conflict(m:&str)->Self { + Self { + status:StatusCode::CONFLICT,message:m.into() + } + } + fn internal(m:&str)->Self { + Self { + status:StatusCode::INTERNAL_SERVER_ERROR,message:m.into() + } + } + fn service_unavailable(m:&str)->Self { + Self { + status:StatusCode::SERVICE_UNAVAILABLE,message:m.into() + } + } + fn database(e:sqlx::Error)->Self { + tracing::error!(error=%e,"authentication database error"); + Self::internal("Database error.") + } +} + +fn email_domain(email: &str) -> &str { + email.rsplit_once('@').map(|(_, domain)| domain).unwrap_or("invalid") +} +impl axum::response::IntoResponse for AuthError { + fn into_response(self)->axum::response::Response { + (self.status,Json(serde_json::json!( { + "error":self.message + } + ))).into_response() + } +} diff --git a/src/database.rs b/src/database.rs index 7351644..691eb3d 100644 --- a/src/database.rs +++ b/src/database.rs @@ -34,8 +34,12 @@ impl Database { Ok(Self { pool, kind }) } - pub fn pool(&self) -> &AnyPool { &self.pool } - pub fn kind(&self) -> DatabaseKind { self.kind } + pub fn pool(&self) -> &AnyPool { + &self.pool + } + pub fn kind(&self) -> DatabaseKind { + self.kind + } } impl DatabaseKind { diff --git a/src/state.rs b/src/state.rs index 61bcb19..4cb76f1 100644 --- a/src/state.rs +++ b/src/state.rs @@ -5,7 +5,9 @@ use tokio::sync::{broadcast, RwLock}; const CHANNEL_CAPACITY: usize = 256; #[derive(Debug, Clone)] -pub struct SmtpConfig { pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String } +pub struct SmtpConfig { + pub host: String, pub port: u16, pub username: String, pub password: String, pub from: String, pub public_url: String +} #[derive(Debug, Clone)] pub struct NoteUpdate { diff --git a/src/websocket.rs b/src/websocket.rs index 6894c9e..61f0938 100644 --- a/src/websocket.rs +++ b/src/websocket.rs @@ -63,10 +63,21 @@ async fn handle_socket(mut socket: WebSocket, state: SharedState, workspace_slug }} info!(workspace_id = workspace.id, note_id = note.id, "note websocket disconnected"); } -fn clean_nickname(value: Option)->Option{value.map(|v|v.trim().chars().take(40).collect::()).filter(|v|!v.is_empty())} -async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error>{send(socket,&ServerMessage::Error{message:message.into()}).await} -async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} -async fn send_split(sender:&mut futures_util::stream::SplitSink,message:&ServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} +fn clean_nickname(value: Option)->Option { + value.map(|v|v.trim().chars().take(40).collect::()).filter(|v|!v.is_empty()) +} +async fn send_error(socket:&mut WebSocket,message:&str)->Result<(),axum::Error> { + send(socket,&ServerMessage::Error { + message:message.into() + } + ).await +} +async fn send(socket:&mut WebSocket,message:&ServerMessage)->Result<(),axum::Error> { + socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +} +async fn send_split(sender:&mut futures_util::stream::SplitSink,message:&ServerMessage)->Result<(),axum::Error> { + sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +} #[derive(Debug, Serialize)] #[serde(tag="type",rename_all="snake_case")] @@ -119,5 +130,9 @@ async fn handle_pad_socket(mut socket:WebSocket,state:SharedState,slug:String){ }} info!(pad_id = pad.id, "pad websocket disconnected"); } -async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error>{socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} -async fn send_pad_split(sender:&mut futures_util::stream::SplitSink,message:&PadServerMessage)->Result<(),axum::Error>{sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await} +async fn send_pad(socket:&mut WebSocket,message:&PadServerMessage)->Result<(),axum::Error> { + socket.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +} +async fn send_pad_split(sender:&mut futures_util::stream::SplitSink,message:&PadServerMessage)->Result<(),axum::Error> { + sender.send(Message::Text(serde_json::to_string(message).unwrap().into())).await +} diff --git a/static/js/session.js b/static/js/session.js index 9a4606b..2760353 100644 --- a/static/js/session.js +++ b/static/js/session.js @@ -2,14 +2,30 @@ export function passwordKey(workspaceSlug) { return `rustpad:workspace:${workspa export function getPassword(workspaceSlug) { return sessionStorage.getItem(passwordKey(workspaceSlug)) || ""; } export function setPassword(workspaceSlug, password) { if (password) sessionStorage.setItem(passwordKey(workspaceSlug), password); else sessionStorage.removeItem(passwordKey(workspaceSlug)); } const NICKNAME_KEY = "rustpad:nickname"; -// Nicknames belong to the current browser session. Remove the old persistent -// value so deleting/logging out of a session cannot leave an identity behind. +const NICKNAME_COOKIE = "rustpad_nickname"; +// A session cookie is shared by tabs, but disappears when the browser session +// ends. The nickname is not an authentication secret. +function getCookie(name) { + const prefix = `${name}=`; + const item = document.cookie.split("; ").find(value => value.startsWith(prefix)); + if (!item) return ""; + try { return decodeURIComponent(item.slice(prefix.length)); } catch { return ""; } +} +function setNicknameCookie(value) { + if (value) document.cookie = `${NICKNAME_COOKIE}=${encodeURIComponent(value)}; Path=/; SameSite=Lax`; + else document.cookie = `${NICKNAME_COOKIE}=; Path=/; SameSite=Lax; Max-Age=0`; +} localStorage.removeItem(NICKNAME_KEY); -export function getNickname() { return sessionStorage.getItem(NICKNAME_KEY) || ""; } +export function getNickname() { + const nickname = sessionStorage.getItem(NICKNAME_KEY) || getCookie(NICKNAME_COOKIE); + if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname); + return nickname; +} export function setNickname(value) { const nickname = value.trim(); if (nickname) sessionStorage.setItem(NICKNAME_KEY, nickname); else sessionStorage.removeItem(NICKNAME_KEY); + setNicknameCookie(nickname); } const AUTH_TOKEN_KEY = "rustpad:auth-token"; @@ -23,6 +39,7 @@ export function clearAuthSession() { sessionStorage.removeItem(AUTH_TOKEN_KEY); localStorage.removeItem(NICKNAME_KEY); sessionStorage.removeItem(NICKNAME_KEY); + setNicknameCookie(""); } export async function resolveIdentity(api, nickname) { const result = await api("/api/auth/identity", { method: "POST", body: JSON.stringify({ nickname, session_token: getAuthToken() || null }) });