update UI

This commit is contained in:
2026-08-11 09:19:13 -07:00
parent 97a3f1be48
commit 4eda93cf24
31 changed files with 952 additions and 95 deletions
+1
View File
@@ -301,6 +301,7 @@ async fn migrate(db: &Db) -> Result<(), String> {
),
(9_i32, include_str!("../migrations/009_gift_effect.sql")),
(10_i32, include_str!("../migrations/010_gift_menu.sql")),
(11_i32, include_str!("../migrations/011_totp_reset.sql")),
] {
let applied = transaction
.query_one(
+275
View File
@@ -891,6 +891,251 @@ impl AuthService {
Ok(codes)
}
/// Begin replacement of the current account's TOTP secret after explicit
/// step-up authentication. The existing secret remains valid until
/// `totp_reset_confirm` commits the replacement.
pub async fn totp_reset_start(
&self,
user_id: Uuid,
current_code: &str,
) -> Result<TotpResetStart, AuthError> {
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
let row = transaction
.query_opt(
"SELECT username,room_id,totp_secret_ciphertext,totp_secret_nonce,last_totp_step \
FROM users WHERE id=$1 AND status='active' FOR UPDATE",
&[&user_id],
)
.await?
.ok_or(AuthError::InvalidCredentials)?;
let username: String = row.get(0);
let room_id: String = row.get(1);
let proof_kind = if is_totp_code(current_code) {
let secret = self.cipher.decrypt(
&EncryptedSecret {
ciphertext: row.get(2),
nonce: row.get(3),
},
format!("user-totp:{user_id}").as_bytes(),
)?;
let accepted_step = accepted_totp_step(
&secret,
&self.issuer,
&username,
current_code,
Utc::now().timestamp(),
row.get(4),
)?
.ok_or(AuthError::InvalidCredentials)?;
let changed = transaction
.execute(
"UPDATE users SET last_totp_step=$1,updated_at=now() \
WHERE id=$2 AND (last_totp_step IS NULL OR last_totp_step<$1)",
&[&accepted_step, &user_id],
)
.await?;
if changed != 1 {
return Err(AuthError::TotpReplay);
}
"totp"
} else {
let recovery_digest = token_digest(current_code.trim());
let recovery_id = transaction
.query_opt(
"SELECT id FROM recovery_codes \
WHERE user_id=$1 AND code_digest=$2 AND consumed_at IS NULL FOR UPDATE",
&[&user_id, &recovery_digest],
)
.await?
.ok_or(AuthError::InvalidCredentials)?
.get::<_, Uuid>(0);
transaction
.execute(
"UPDATE recovery_codes SET consumed_at=now() WHERE id=$1",
&[&recovery_id],
)
.await?;
"recovery"
};
let reset_id = Uuid::new_v4();
let enrollment_token = random_token("totp-reset", 32);
let enrollment_digest = token_digest(&enrollment_token);
let secret = Secret::generate_secret();
let secret_bytes = secret
.to_bytes()
.map_err(|error| AuthError::Totp(error.to_string()))?;
let encoded = secret.to_encoded();
let encoded_secret = match &encoded {
Secret::Encoded(value) => value.clone(),
Secret::Raw(_) => unreachable!("to_encoded always returns Secret::Encoded"),
};
let totp = build_totp(&secret_bytes, &self.issuer, &username)?;
let otpauth_uri = totp.get_url();
let qr = totp
.get_qr_base64()
.map_err(|error| AuthError::Totp(error.to_string()))?;
let qr_data_url = if qr.starts_with("data:") {
qr
} else {
format!("data:image/png;base64,{qr}")
};
let encrypted = self.cipher.encrypt(
&secret_bytes,
format!("pending-totp-reset:{reset_id}").as_bytes(),
)?;
let expires_at = future_time(self.enrollment_ttl)?;
Db::set_tenant(&transaction, user_id).await?;
transaction
.execute(
"DELETE FROM pending_totp_resets WHERE user_id=$1",
&[&user_id],
)
.await?;
transaction
.execute(
"INSERT INTO pending_totp_resets \
(id,user_id,enrollment_token_digest,totp_secret_ciphertext,totp_secret_nonce,expires_at) \
VALUES($1,$2,$3,$4,$5,$6)",
&[
&reset_id,
&user_id,
&enrollment_digest,
&encrypted.ciphertext,
&encrypted.nonce,
&expires_at,
],
)
.await?;
insert_audit(
&transaction,
Some(user_id),
"auth.totp_reset.started",
"user",
Some(user_id.to_string()),
json!({"proof":proof_kind}),
)
.await?;
transaction.commit().await?;
Ok(TotpResetStart {
enrollment_token,
username,
room_id,
secret: encoded_secret,
otpauth_uri,
qr_data_url,
expires_at,
})
}
/// Confirm a pending replacement, rotate recovery codes, and revoke every
/// other browser session atomically. The session performing the reset is
/// retained so it can display the one-time recovery codes.
pub async fn totp_reset_confirm(
&self,
user_id: Uuid,
current_session_id: Uuid,
enrollment_token: &str,
new_totp_code: &str,
) -> Result<Vec<String>, AuthError> {
let digest = token_digest(enrollment_token.trim());
let mut client = self.db.get().await?;
let transaction = client.transaction().await?;
Db::set_tenant(&transaction, user_id).await?;
let row = transaction
.query_opt(
"SELECT r.id,r.totp_secret_ciphertext,r.totp_secret_nonce,r.expires_at,\
r.failed_attempts,u.username \
FROM pending_totp_resets r JOIN users u ON u.id=r.user_id \
WHERE r.user_id=$1 AND r.enrollment_token_digest=$2 FOR UPDATE OF r,u",
&[&user_id, &digest],
)
.await?
.ok_or(AuthError::TotpResetUnavailable)?;
let reset_id: Uuid = row.get(0);
let expires_at: DateTime<Utc> = row.get(3);
let failed_attempts: i32 = row.get(4);
if expires_at <= Utc::now() {
return Err(AuthError::TotpResetUnavailable);
}
let username: String = row.get(5);
let secret = self.cipher.decrypt(
&EncryptedSecret {
ciphertext: row.get(1),
nonce: row.get(2),
},
format!("pending-totp-reset:{reset_id}").as_bytes(),
)?;
let accepted_step = accepted_totp_step(
&secret,
&self.issuer,
&username,
new_totp_code,
Utc::now().timestamp(),
None,
)?;
let Some(accepted_step) = accepted_step else {
if failed_attempts + 1 >= MAX_PENDING_TOTP_FAILURES {
transaction
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
.await?;
} else {
transaction
.execute(
"UPDATE pending_totp_resets SET failed_attempts=failed_attempts+1 WHERE id=$1",
&[&reset_id],
)
.await?;
}
transaction.commit().await?;
return Err(AuthError::InvalidTotp);
};
let encrypted = self
.cipher
.encrypt(&secret, format!("user-totp:{user_id}").as_bytes())?;
let updated = transaction
.execute(
"UPDATE users SET totp_secret_ciphertext=$1,totp_secret_nonce=$2,\
last_totp_step=$3,totp_enrolled_at=now(),auth_version=auth_version+1,updated_at=now() \
WHERE id=$4 AND status='active'",
&[
&encrypted.ciphertext,
&encrypted.nonce,
&accepted_step,
&user_id,
],
)
.await?;
if updated != 1 {
return Err(AuthError::TotpResetUnavailable);
}
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
let revoked_sessions = transaction
.execute(
"UPDATE user_sessions SET revoked_at=now() \
WHERE user_id=$1 AND id<>$2 AND revoked_at IS NULL",
&[&user_id, &current_session_id],
)
.await?;
transaction
.execute("DELETE FROM pending_totp_resets WHERE id=$1", &[&reset_id])
.await?;
insert_audit(
&transaction,
Some(user_id),
"auth.totp_reset.completed",
"user",
Some(user_id.to_string()),
json!({"revokedSessions":revoked_sessions}),
)
.await?;
transaction.commit().await?;
Ok(recovery_codes)
}
pub async fn authenticate_session(
&self,
raw_session_token: &str,
@@ -1510,6 +1755,11 @@ fn accepted_totp_step(
Ok(None)
}
fn is_totp_code(code: &str) -> bool {
let code = code.trim();
code.len() == TOTP_DIGITS && code.bytes().all(|byte| byte.is_ascii_digit())
}
async fn require_system_admin(
transaction: &Transaction<'_>,
user_id: Uuid,
@@ -1790,6 +2040,18 @@ pub struct RegistrationStart {
pub expires_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct TotpResetStart {
pub enrollment_token: String,
pub username: String,
pub room_id: String,
pub secret: String,
pub otpauth_uri: String,
pub qr_data_url: String,
pub expires_at: DateTime<Utc>,
}
#[derive(Clone, Debug, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RegistrationComplete {
@@ -1847,6 +2109,7 @@ pub enum AuthError {
Forbidden,
InvitationUnavailable,
EnrollmentUnavailable,
TotpResetUnavailable,
AccountUnavailable,
RoomUnavailable,
InvalidTotp,
@@ -1879,6 +2142,9 @@ impl fmt::Display for AuthError {
formatter,
"registration enrollment is invalid or unavailable"
),
Self::TotpResetUnavailable => {
write!(formatter, "TOTP reset is invalid or unavailable")
}
Self::AccountUnavailable => write!(formatter, "username or room is unavailable"),
Self::RoomUnavailable => write!(formatter, "room is already assigned"),
Self::InvalidTotp => write!(formatter, "invalid TOTP code"),
@@ -1968,6 +2234,15 @@ mod tests {
);
}
#[test]
fn step_up_proof_only_classifies_exact_six_digit_totp_codes() {
assert!(is_totp_code(" 012345 "));
assert!(!is_totp_code("12345"));
assert!(!is_totp_code("1234567"));
assert!(!is_totp_code("12a456"));
assert!(!is_totp_code("recovery-example"));
}
#[test]
fn username_and_room_validation_are_canonical() {
assert_eq!(
+4
View File
@@ -133,6 +133,7 @@ struct OverlayFileConfig {
viewer_color: Option<String>,
danmaku_color: Option<String>,
font_scale: Option<u16>,
decoration_line_weight: Option<u16>,
max_visible: Option<u8>,
collapse_after_seconds: Option<u16>,
unfold_duration_ms: Option<u16>,
@@ -333,6 +334,9 @@ fn overlay_defaults(file: OverlayFileConfig) -> OverlaySettings {
viewer_color: file.viewer_color.or(default.viewer_color),
danmaku_color: file.danmaku_color.or(default.danmaku_color),
font_scale: file.font_scale.unwrap_or(default.font_scale),
decoration_line_weight: file
.decoration_line_weight
.unwrap_or(default.decoration_line_weight),
show_danmaku: file.events.danmaku.unwrap_or(default.show_danmaku),
show_enter: file.events.enter.unwrap_or(default.show_enter),
show_gift: file.events.gift.unwrap_or(default.show_gift),
+15
View File
@@ -93,6 +93,8 @@ pub struct GiftMenuSettings {
pub page_interval_ms: u16,
pub highlight_duration_ms: u16,
pub font_scale: u16,
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
pub motion_intensity: u8,
pub low_performance_mode: bool,
}
@@ -110,6 +112,7 @@ impl Default for GiftMenuSettings {
page_interval_ms: default_page_interval_ms(),
highlight_duration_ms: 3_800,
font_scale: 100,
decoration_line_weight: default_decoration_line_weight(),
motion_intensity: 78,
low_performance_mode: false,
}
@@ -140,11 +143,16 @@ impl GiftMenuSettings {
self.page_interval_ms = self.page_interval_ms.clamp(1_500, 30_000);
self.highlight_duration_ms = self.highlight_duration_ms.clamp(600, 12_000);
self.font_scale = self.font_scale.clamp(50, 220);
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
self.motion_intensity = self.motion_intensity.min(100);
Ok(self)
}
}
const fn default_decoration_line_weight() -> u16 {
160
}
fn normalize_text(value: &str, max_chars: usize) -> Result<String, String> {
let normalized = value.split_whitespace().collect::<Vec<_>>().join(" ");
let count = normalized.chars().count();
@@ -385,6 +393,7 @@ mod tests {
page_interval_ms: u16::MAX,
font_scale: 1,
font_brightness: 1,
decoration_line_weight: 1,
..GiftMenuSettings::default()
};
let sanitized = definition
@@ -396,6 +405,7 @@ mod tests {
assert_eq!(sanitized["pageIntervalMs"], 30_000);
assert_eq!(sanitized["fontScale"], 50);
assert_eq!(sanitized["fontBrightness"], 70);
assert_eq!(sanitized["decorationLineWeight"], 50);
}
#[test]
@@ -403,10 +413,15 @@ mod tests {
let definition = GiftMenuDefinition;
let mut settings = serde_json::to_value(GiftMenuSettings::default()).unwrap();
settings.as_object_mut().unwrap().remove("pageIntervalMs");
settings
.as_object_mut()
.unwrap()
.remove("decorationLineWeight");
let sanitized = definition.validate_settings(settings).unwrap();
assert_eq!(sanitized["pageIntervalMs"], default_page_interval_ms());
assert_eq!(sanitized["decorationLineWeight"], 160);
}
#[test]
+57
View File
@@ -54,6 +54,8 @@ pub fn router(state: AppState) -> Router {
.route("/api/v1/auth/register/confirm", post(enrollment_confirm))
.route("/api/v1/auth/login", post(login))
.route("/api/v1/auth/logout", post(logout))
.route("/api/v1/auth/totp/reset/start", post(totp_reset_start))
.route("/api/v1/auth/totp/reset/confirm", post(totp_reset_confirm))
.route(
"/api/v1/invitations",
get(list_invitations).post(create_invitation),
@@ -433,6 +435,56 @@ async fn logout(State(state): State<AppState>, headers: HeaderMap) -> Result<Res
Ok(response)
}
#[derive(Deserialize)]
struct TotpResetStartRequest {
code: String,
}
async fn totp_reset_start(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TotpResetStartRequest>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
consume_enrollment_budget(&state, &headers).await?;
let enrollment = state
.auth
.totp_reset_start(session.user.id, &body.code)
.await?;
Ok(Json(serde_json::to_value(enrollment).map_err(internal)?))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct TotpResetConfirmRequest {
enrollment_token: String,
code: String,
}
async fn totp_reset_confirm(
State(state): State<AppState>,
headers: HeaderMap,
Json(body): Json<TotpResetConfirmRequest>,
) -> Result<Json<Value>, ApiError> {
same_origin(&state, &headers)?;
let session = require_session(&state, &headers).await?;
consume_enrollment_budget(&state, &headers).await?;
let recovery_codes = state
.auth
.totp_reset_confirm(
session.user.id,
session.session_id,
&body.enrollment_token,
&body.code,
)
.await?;
Ok(Json(json!({
"ok": true,
"recoveryCodes": recovery_codes,
})))
}
#[derive(Deserialize)]
#[serde(rename_all = "camelCase")]
struct CreateInvitationRequest {
@@ -1564,6 +1616,11 @@ impl From<AuthError> for ApiError {
"enrollment_unavailable",
error.to_string(),
),
AuthError::TotpResetUnavailable => Self::new(
StatusCode::BAD_REQUEST,
"totp_reset_unavailable",
error.to_string(),
),
AuthError::AccountUnavailable => Self::new(
StatusCode::CONFLICT,
"account_unavailable",
+13
View File
@@ -46,6 +46,9 @@ pub struct OverlaySettings {
pub danmaku_color: Option<String>,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
/// Relative weight of theme borders, rules, and ornamental edge artwork.
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
pub show_danmaku: bool,
pub show_enter: bool,
pub show_gift: bool,
@@ -77,6 +80,7 @@ impl Default for OverlaySettings {
viewer_color: None,
danmaku_color: None,
font_scale: default_font_scale(),
decoration_line_weight: default_decoration_line_weight(),
show_danmaku: true,
show_enter: true,
show_gift: true,
@@ -104,6 +108,7 @@ impl OverlaySettings {
self.viewer_color = sanitize_optional_hex_color(self.viewer_color);
self.danmaku_color = sanitize_optional_hex_color(self.danmaku_color);
self.font_scale = self.font_scale.clamp(50, 300);
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
self.collapse_after_seconds = self.collapse_after_seconds.clamp(2, 120);
self.unfold_duration_ms = self.unfold_duration_ms.clamp(200, 5_000);
self.motion_intensity = self.motion_intensity.min(100);
@@ -128,6 +133,10 @@ fn default_font_scale() -> u16 {
140
}
fn default_decoration_line_weight() -> u16 {
160
}
fn default_unfold_duration_ms() -> u16 {
1_000
}
@@ -577,6 +586,7 @@ mod tests {
font_brightness: 1,
viewer_color: Some(" #a1b2c3 ".into()),
danmaku_color: Some("not-css".into()),
decoration_line_weight: 999,
max_visible: 99,
collapse_after_seconds: 1,
unfold_duration_ms: 9_000,
@@ -592,6 +602,7 @@ mod tests {
assert_eq!(settings.font_brightness, 70);
assert_eq!(settings.viewer_color.as_deref(), Some("#A1B2C3"));
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.decoration_line_weight, 300);
assert_eq!(settings.max_visible, 12);
assert_eq!(settings.collapse_after_seconds, 2);
assert_eq!(settings.unfold_duration_ms, 5_000);
@@ -618,6 +629,7 @@ mod tests {
object.remove("fontBrightness");
object.remove("viewerColor");
object.remove("danmakuColor");
object.remove("decorationLineWeight");
object.remove("unfoldDurationMs");
object.remove("particleCount");
object.remove("particleSpeed");
@@ -629,6 +641,7 @@ mod tests {
assert_eq!(settings.viewer_color, None);
assert_eq!(settings.danmaku_color, None);
assert_eq!(settings.font_scale, 140);
assert_eq!(settings.decoration_line_weight, 160);
assert_eq!(settings.unfold_duration_ms, 1_000);
assert_eq!(settings.particle_count, 8);
assert_eq!(settings.particle_speed, 100);
+10
View File
@@ -46,6 +46,8 @@ pub struct SongRequestSettings {
pub song_title_color: Option<String>,
#[serde(default = "default_font_scale")]
pub font_scale: u16,
#[serde(default = "default_decoration_line_weight")]
pub decoration_line_weight: u16,
#[serde(default = "default_scroll_speed")]
pub scroll_speed_pixels_per_second: u16,
#[serde(default = "default_edge_pause")]
@@ -70,6 +72,7 @@ impl Default for SongRequestSettings {
requester_color: None,
song_title_color: None,
font_scale: default_font_scale(),
decoration_line_weight: default_decoration_line_weight(),
scroll_speed_pixels_per_second: default_scroll_speed(),
edge_pause_seconds: default_edge_pause(),
max_queue_size: 0,
@@ -82,6 +85,7 @@ impl Default for SongRequestSettings {
impl SongRequestSettings {
pub fn sanitize(mut self) -> Self {
self.font_scale = self.font_scale.clamp(50, 250);
self.decoration_line_weight = self.decoration_line_weight.clamp(50, 300);
self.font_brightness = sanitize_font_brightness(self.font_brightness);
self.requester_color = sanitize_optional_hex_color(self.requester_color);
self.song_title_color = sanitize_optional_hex_color(self.song_title_color);
@@ -106,6 +110,10 @@ const fn default_font_scale() -> u16 {
100
}
const fn default_decoration_line_weight() -> u16 {
160
}
const fn default_scroll_speed() -> u16 {
28
}
@@ -1098,6 +1106,7 @@ mod tests {
font_brightness: u16::MAX,
requester_color: Some(" #a1b2c3 ".into()),
song_title_color: Some("transparent".into()),
decoration_line_weight: 999,
scroll_speed_pixels_per_second: 0,
edge_pause_seconds: 200,
max_queue_size: u32::MAX,
@@ -1110,6 +1119,7 @@ mod tests {
assert_eq!(bounded.font_brightness, 180);
assert_eq!(bounded.requester_color.as_deref(), Some("#A1B2C3"));
assert_eq!(bounded.song_title_color, None);
assert_eq!(bounded.decoration_line_weight, 300);
assert_eq!(bounded.scroll_speed_pixels_per_second, 5);
assert_eq!(bounded.edge_pause_seconds, 15);
assert_eq!(bounded.max_queue_size, 10_000);