update UI
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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, ¤t_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!(
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user