pub mod auth;
pub mod cosmetics;
use std::{collections::HashMap, ops::Deref, sync::Arc};
use anyhow::{Context, bail};
use nitro_auth::mc::{AccessToken, ClientId, Keypair};
use nitro_shared::{
minecraft::{Cape, MinecraftUserProfile, Skin, SkinVariant},
output::NitroOutput,
};
use reqwest::Client;
use crate::Paths;
use self::auth::AuthParameters;
pub type AccountID = Arc<str>;
#[derive(Debug, Clone)]
pub struct Account {
pub(crate) kind: AccountKind,
id: AccountID,
name: Option<String>,
uuid: Option<String>,
access_token: Option<AccessToken>,
keypair: Option<Keypair>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum AccountKind {
Microsoft {
xbox_uid: Option<String>,
},
Demo,
Unknown(String),
}
impl Account {
pub fn new(kind: AccountKind, id: AccountID) -> Self {
Self {
kind,
id,
name: None,
uuid: None,
access_token: None,
keypair: None,
}
}
pub fn get_id(&self) -> &AccountID {
&self.id
}
pub fn get_name(&self) -> Option<&str> {
self.name.as_deref()
}
pub fn is_microsoft(&self) -> bool {
matches!(self.kind, AccountKind::Microsoft { .. })
}
pub fn is_demo(&self) -> bool {
matches!(self.kind, AccountKind::Demo)
}
pub fn get_kind(&self) -> &AccountKind {
&self.kind
}
pub fn set_uuid(&mut self, uuid: &str) {
self.uuid = Some(uuid.to_string());
}
pub fn get_uuid(&self) -> Option<&str> {
self.uuid.as_deref()
}
pub fn get_access_token(&self) -> Option<&AccessToken> {
self.access_token.as_ref()
}
pub fn get_xbox_uid(&self) -> Option<&String> {
if let AccountKind::Microsoft { xbox_uid } = &self.kind {
xbox_uid.as_ref()
} else {
None
}
}
pub fn get_keypair(&self) -> Option<&Keypair> {
self.keypair.as_ref()
}
pub fn validate_username(&self) -> bool {
if let Some(name) = &self.name {
if name.is_empty() || name.len() > 16 {
return false;
}
for c in name.chars() {
if !c.is_ascii_alphanumeric() && c != '_' {
return false;
}
}
}
true
}
}
#[derive(Clone)]
pub struct AccountManager {
state: AuthState,
accounts: HashMap<AccountID, Account>,
ms_client_id: ClientId,
offline: bool,
custom_hooks: Option<Arc<dyn AccountManagerHooks>>,
}
#[derive(Debug, Clone)]
enum AuthState {
Offline,
AccountChosen(AccountID),
}
impl AccountManager {
pub fn new(ms_client_id: ClientId) -> Self {
Self {
state: AuthState::Offline,
accounts: HashMap::new(),
ms_client_id,
offline: false,
custom_hooks: None,
}
}
pub fn add_account(&mut self, account: Account) {
self.add_account_with_id(account.id.clone(), account);
}
pub fn add_account_with_id(&mut self, account_id: AccountID, account: Account) {
self.accounts.insert(account_id, account);
}
pub fn get_account(&self, account_id: &str) -> Option<&Account> {
self.accounts.get(account_id)
}
pub fn get_account_mut(&mut self, account_id: &str) -> Option<&mut Account> {
self.accounts.get_mut(account_id)
}
pub fn account_exists(&self, account_id: &str) -> bool {
self.accounts.contains_key(account_id)
}
pub fn iter_accounts(&self) -> impl Iterator<Item = (&AccountID, &Account)> {
self.accounts.iter()
}
pub fn remove_account(&mut self, account_id: &str) {
let is_chosen = if let Some(chosen) = self.get_chosen_account() {
chosen.get_id().deref() == account_id
} else {
false
};
if is_chosen {
self.unchoose_account();
}
self.accounts.remove(account_id);
}
pub fn choose_account(&mut self, account_id: &str) -> anyhow::Result<()> {
if !self.account_exists(account_id) {
bail!("Chosen account does not exist");
}
self.state = AuthState::AccountChosen(account_id.into());
Ok(())
}
pub fn get_chosen_account_id(&self) -> Option<&str> {
match &self.state {
AuthState::Offline => None,
AuthState::AccountChosen(account_id) => Some(account_id),
}
}
pub fn get_chosen_account(&self) -> Option<&Account> {
match &self.state {
AuthState::Offline => None,
AuthState::AccountChosen(account_id) => self.accounts.get(account_id),
}
}
pub fn get_chosen_account_mut(&mut self) -> Option<&mut Account> {
match &self.state {
AuthState::Offline => None,
AuthState::AccountChosen(account_id) => self.accounts.get_mut(account_id),
}
}
pub fn is_account_chosen(&self) -> bool {
matches!(self.state, AuthState::AccountChosen(..))
}
pub fn is_authenticated(&self) -> bool {
let Some(account) = self.get_chosen_account() else {
return false;
};
account.is_authenticated()
}
pub async fn authenticate(
&mut self,
offline: bool,
paths: &Paths,
client: &Client,
o: &mut impl NitroOutput,
) -> anyhow::Result<()> {
if let AuthState::AccountChosen(account_id) = &mut self.state {
let account = self
.accounts
.get_mut(account_id)
.expect("Account in AuthState does not exist");
if !account.is_authenticated() || !account.is_auth_valid(paths) {
let params = AuthParameters {
req_client: client,
paths,
force: false,
offline,
client_id: self.ms_client_id.clone(),
custom_hooks: self.custom_hooks.clone(),
};
account.authenticate(params, o).await?;
}
}
Ok(())
}
pub async fn authenticate_account(
&mut self,
account: &str,
paths: &Paths,
client: &Client,
o: &mut impl NitroOutput,
) -> anyhow::Result<()> {
let account = self
.accounts
.get_mut(account)
.context("Account does not exist")?;
if !account.is_authenticated() || !account.is_auth_valid(paths) {
let params = AuthParameters {
req_client: client,
paths,
force: false,
offline: self.offline,
client_id: self.ms_client_id.clone(),
custom_hooks: self.custom_hooks.clone(),
};
account.authenticate(params, o).await?;
}
Ok(())
}
pub async fn get_cosmetics(
&mut self,
paths: &Paths,
client: &Client,
o: &mut impl NitroOutput,
) -> anyhow::Result<(Vec<Skin>, Vec<Cape>)> {
if let AuthState::AccountChosen(account_id) = &mut self.state {
let account = self
.accounts
.get_mut(account_id)
.expect("Account in AuthState does not exist");
let params = AuthParameters {
req_client: client,
paths,
force: false,
offline: self.offline,
client_id: self.ms_client_id.clone(),
custom_hooks: self.custom_hooks.clone(),
};
account.get_cosmetics(params, o).await
} else {
bail!("No account chosen")
}
}
pub async fn get_account_cosmetics(
&mut self,
account: &str,
paths: &Paths,
client: &Client,
o: &mut impl NitroOutput,
) -> anyhow::Result<(Vec<Skin>, Vec<Cape>)> {
let account = self
.accounts
.get_mut(account)
.context("Account does not exist")?;
let params = AuthParameters {
req_client: client,
paths,
force: false,
offline: self.offline,
client_id: self.ms_client_id.clone(),
custom_hooks: self.custom_hooks.clone(),
};
account.get_cosmetics(params, o).await
}
pub async fn upload_skin(
&mut self,
account: &str,
variant: SkinVariant,
skin: &[u8],
paths: &Paths,
client: &Client,
o: &mut impl NitroOutput,
) -> anyhow::Result<()> {
let account = self
.accounts
.get_mut(account)
.context("Account does not exist")?;
let params = AuthParameters {
req_client: client,
paths,
force: false,
offline: self.offline,
client_id: self.ms_client_id.clone(),
custom_hooks: self.custom_hooks.clone(),
};
account.upload_skin(variant, skin, params, o).await
}
pub async fn activate_cape(
&mut self,
account: &str,
cape: Option<&str>,
paths: &Paths,
client: &Client,
o: &mut impl NitroOutput,
) -> anyhow::Result<()> {
let account = self
.accounts
.get_mut(account)
.context("Account does not exist")?;
let params = AuthParameters {
req_client: client,
paths,
force: false,
offline: self.offline,
client_id: self.ms_client_id.clone(),
custom_hooks: self.custom_hooks.clone(),
};
account.activate_cape(cape, params, o).await
}
pub fn unchoose_account(&mut self) {
self.state = AuthState::Offline;
}
pub fn steal_accounts(&mut self, other: &Self) {
self.accounts.extend(other.accounts.clone());
self.state = other.state.clone();
}
pub fn set_offline(&mut self, offline: bool) {
self.offline = offline;
}
pub fn set_custom_hooks(&mut self, hooks: Arc<dyn AccountManagerHooks>) {
self.custom_hooks = Some(hooks);
}
}
#[async_trait::async_trait]
pub trait AccountManagerHooks: Send + Sync {
async fn auth(
&self,
id: &str,
account_type: &str,
) -> anyhow::Result<Option<MinecraftUserProfile>>;
async fn get_cosmetics(
&self,
id: &str,
account_type: &str,
) -> anyhow::Result<(Vec<Skin>, Vec<Cape>)>;
async fn upload_skin(
&self,
id: &str,
account_type: &str,
skin: &[u8],
variant: SkinVariant,
) -> anyhow::Result<()>;
async fn activate_cape(
&self,
id: &str,
account_type: &str,
cape: Option<&str>,
) -> anyhow::Result<()>;
}
pub fn validate_username(_kind: &AccountKind, name: &str) -> bool {
if name.is_empty() || name.len() > 16 {
return false;
}
for c in name.chars() {
if !c.is_ascii_alphanumeric() && c != '_' {
return false;
}
}
true
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_username_validation() {
assert!(validate_username(
&AccountKind::Microsoft { xbox_uid: None },
"CarbonSmasher"
));
assert!(validate_username(&AccountKind::Demo, "12345"));
assert!(validate_username(
&AccountKind::Microsoft { xbox_uid: None },
"Foo_Bar888"
));
assert!(!validate_username(
&AccountKind::Microsoft { xbox_uid: None },
""
));
assert!(!validate_username(
&AccountKind::Microsoft { xbox_uid: None },
"ABCDEFGHIJKLMNOPQRS"
));
assert!(!validate_username(
&AccountKind::Microsoft { xbox_uid: None },
"+++"
));
}
#[test]
fn test_account_manager() {
let mut accounts = AccountManager::new(ClientId::new(String::new()));
let account = Account::new(AccountKind::Demo, "foo".into());
accounts.add_account(account);
accounts
.choose_account("foo")
.expect("Failed to choose account");
let account = Account::new(AccountKind::Demo, "bar".into());
accounts.add_account(account);
accounts.remove_account("foo");
assert!(!accounts.is_account_chosen());
assert!(!accounts.account_exists("foo"));
}
}