add account localization and switchable rooms
Centralize control and OBS copy in a shared TOML catalog, persist the selected locale per account, and broadcast language changes to component streams. Allow account owners to atomically switch their Bilibili room and restart the shared listener without changing component URLs.
This commit is contained in:
+134
-26
@@ -19,13 +19,14 @@ use serde::{Deserialize, Serialize};
|
||||
use serde_json::{Value, json};
|
||||
use sha2::{Digest, Sha256};
|
||||
use subtle::ConstantTimeEq;
|
||||
use tokio_postgres::Transaction;
|
||||
use tokio_postgres::{Transaction, error::SqlState};
|
||||
use totp_rs::{Algorithm, Secret, TOTP};
|
||||
use uuid::Uuid;
|
||||
|
||||
use crate::{
|
||||
credentials::{CookieCloudCredentials, CookieCloudSecrets, normalize_cookiecloud_host},
|
||||
db::{Db, DbError},
|
||||
i18n,
|
||||
overlay::OverlaySettings,
|
||||
song_request::{SONG_REQUEST_KIND, SONG_REQUEST_NAME, SongRequestSettings},
|
||||
};
|
||||
@@ -479,7 +480,11 @@ impl AuthService {
|
||||
&self,
|
||||
enrollment_token: &str,
|
||||
totp_code: &str,
|
||||
language: &str,
|
||||
) -> Result<RegistrationComplete, AuthError> {
|
||||
if !i18n::is_supported(language) {
|
||||
return Err(AuthError::InvalidInput("language is not supported"));
|
||||
}
|
||||
let digest = token_digest(enrollment_token.trim());
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
@@ -598,6 +603,12 @@ impl AuthService {
|
||||
let recovery_codes = replace_recovery_codes(&transaction, user_id).await?;
|
||||
let session = insert_session(&transaction, user_id, self.session_ttl).await?;
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO account_preferences(user_id,language) VALUES($1,$2)",
|
||||
&[&user_id, &language],
|
||||
)
|
||||
.await?;
|
||||
let default_source_id = Uuid::new_v4();
|
||||
transaction
|
||||
.execute(
|
||||
@@ -909,6 +920,100 @@ impl AuthService {
|
||||
user_id: Uuid,
|
||||
credentials: &CookieCloudCredentials,
|
||||
) -> Result<(), AuthError> {
|
||||
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
ensure_active_user(&transaction, user_id).await?;
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"cookiecloud.credentials.updated",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({"host":host}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Atomically changes the account's current room and encrypted CookieCloud
|
||||
/// source. The live source keeps its stable ID, allowing the supervisor to
|
||||
/// cancel the old room connection and start the replacement without
|
||||
/// changing any component or OBS token.
|
||||
pub async fn set_account_live_source(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
room_id: &str,
|
||||
credentials: &CookieCloudCredentials,
|
||||
) -> Result<(), AuthError> {
|
||||
let room_id = room_id.trim();
|
||||
validate_room_id(room_id)?;
|
||||
let (host, encrypted) = self.encrypt_cookiecloud_credentials(user_id, credentials)?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
ensure_active_user(&transaction, user_id).await?;
|
||||
let old_room_id: String = transaction
|
||||
.query_one("SELECT room_id FROM users WHERE id=$1", &[&user_id])
|
||||
.await?
|
||||
.get(0);
|
||||
let room_in_use: bool = transaction
|
||||
.query_one(
|
||||
"SELECT EXISTS(SELECT 1 FROM users WHERE room_id=$1 AND id<>$2)",
|
||||
&[&room_id, &user_id],
|
||||
)
|
||||
.await?
|
||||
.get(0);
|
||||
if room_in_use {
|
||||
return Err(AuthError::RoomUnavailable);
|
||||
}
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
transaction
|
||||
.execute(
|
||||
"UPDATE users SET room_id=$1,updated_at=now() WHERE id=$2 AND status='active'",
|
||||
&[&room_id, &user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(map_room_write_error)?;
|
||||
let changed = transaction
|
||||
.execute(
|
||||
"UPDATE live_sources SET room_id=$1,updated_at=now() \
|
||||
WHERE owner_user_id=$2",
|
||||
&[&room_id, &user_id],
|
||||
)
|
||||
.await
|
||||
.map_err(map_room_write_error)?;
|
||||
if changed != 1 {
|
||||
return Err(AuthError::DatabaseInvariant(
|
||||
"account must own exactly one live source",
|
||||
));
|
||||
}
|
||||
upsert_cookiecloud_credentials(&transaction, user_id, &host, &encrypted).await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"account.live_source.updated",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({
|
||||
"host": host,
|
||||
"oldRoomId": old_room_id,
|
||||
"newRoomId": room_id,
|
||||
"roomChanged": old_room_id != room_id,
|
||||
}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn encrypt_cookiecloud_credentials(
|
||||
&self,
|
||||
user_id: Uuid,
|
||||
credentials: &CookieCloudCredentials,
|
||||
) -> Result<(String, EncryptedSecret), AuthError> {
|
||||
let host = normalize_cookiecloud_host(&credentials.host)
|
||||
.map_err(|_| AuthError::InvalidInput("CookieCloud host is invalid"))?;
|
||||
if credentials.secrets.key.trim().is_empty() || credentials.secrets.password.is_empty() {
|
||||
@@ -924,31 +1029,7 @@ impl AuthService {
|
||||
let encrypted = self
|
||||
.cipher
|
||||
.encrypt(&plaintext, format!("cookiecloud:{user_id}").as_bytes())?;
|
||||
let mut client = self.db.get().await?;
|
||||
let transaction = client.transaction().await?;
|
||||
ensure_active_user(&transaction, user_id).await?;
|
||||
Db::set_tenant(&transaction, user_id).await?;
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO cookiecloud_credentials \
|
||||
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
|
||||
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
|
||||
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
|
||||
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
|
||||
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
|
||||
)
|
||||
.await?;
|
||||
insert_audit(
|
||||
&transaction,
|
||||
Some(user_id),
|
||||
"cookiecloud.credentials.updated",
|
||||
"user",
|
||||
Some(user_id.to_string()),
|
||||
json!({"host":host}),
|
||||
)
|
||||
.await?;
|
||||
transaction.commit().await?;
|
||||
Ok(())
|
||||
Ok((host, encrypted))
|
||||
}
|
||||
|
||||
pub async fn get_cookiecloud_credentials(
|
||||
@@ -1274,6 +1355,33 @@ impl AuthService {
|
||||
}
|
||||
}
|
||||
|
||||
async fn upsert_cookiecloud_credentials(
|
||||
transaction: &Transaction<'_>,
|
||||
user_id: Uuid,
|
||||
host: &str,
|
||||
encrypted: &EncryptedSecret,
|
||||
) -> Result<(), AuthError> {
|
||||
transaction
|
||||
.execute(
|
||||
"INSERT INTO cookiecloud_credentials \
|
||||
(user_id,host,secrets_ciphertext,secrets_nonce) VALUES($1,$2,$3,$4) \
|
||||
ON CONFLICT(user_id) DO UPDATE SET host=EXCLUDED.host,\
|
||||
secrets_ciphertext=EXCLUDED.secrets_ciphertext,\
|
||||
secrets_nonce=EXCLUDED.secrets_nonce,updated_at=now()",
|
||||
&[&user_id, &host, &encrypted.ciphertext, &encrypted.nonce],
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn map_room_write_error(error: tokio_postgres::Error) -> AuthError {
|
||||
if error.code() == Some(&SqlState::UNIQUE_VIOLATION) {
|
||||
AuthError::RoomUnavailable
|
||||
} else {
|
||||
error.into()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct SecretCipher {
|
||||
cipher: Arc<XChaCha20Poly1305>,
|
||||
|
||||
Reference in New Issue
Block a user