diff options
Diffstat (limited to 'src/login.rs')
| -rw-r--r-- | src/login.rs | 461 |
1 files changed, 369 insertions, 92 deletions
diff --git a/src/login.rs b/src/login.rs index 4cdf3c1..58d1b87 100644 --- a/src/login.rs +++ b/src/login.rs | |||
| @@ -2,130 +2,407 @@ use std::str::FromStr; | |||
| 2 | 2 | ||
| 3 | use anyhow::{bail, Context, Result}; | 3 | use anyhow::{bail, Context, Result}; |
| 4 | use nostr::PublicKey; | 4 | use nostr::PublicKey; |
| 5 | use zeroize::Zeroize; | 5 | use nostr_database::Order; |
| 6 | use nostr_sdk::{Alphabet, FromBech32, JsonUtil, Kind, NostrDatabase, SingleLetterTag, ToBech32}; | ||
| 7 | use nostr_sqlite::SQLiteDatabase; | ||
| 6 | 8 | ||
| 7 | #[cfg(not(test))] | 9 | #[cfg(not(test))] |
| 8 | use crate::client::Client; | 10 | use crate::client::Client; |
| 9 | #[cfg(test)] | 11 | #[cfg(test)] |
| 10 | use crate::client::MockConnect; | 12 | use crate::client::MockConnect; |
| 11 | use crate::{ | 13 | use crate::{ |
| 12 | cli_interactor::{Interactor, InteractorPrompt, PromptPasswordParms}, | 14 | cli_interactor::{ |
| 13 | config::{ConfigManagement, ConfigManager, UserRef}, | 15 | Interactor, InteractorPrompt, PromptConfirmParms, PromptInputParms, PromptPasswordParms, |
| 14 | key_handling::{ | ||
| 15 | encryption::{EncryptDecrypt, Encryptor}, | ||
| 16 | users::{UserManagement, UserManager}, | ||
| 17 | }, | 16 | }, |
| 17 | client::Connect, | ||
| 18 | config::{get_dirs, UserMetadata, UserRef, UserRelayRef, UserRelays}, | ||
| 19 | git::{Repo, RepoActions}, | ||
| 20 | key_handling::encryption::{decrypt_key, encrypt_key}, | ||
| 18 | }; | 21 | }; |
| 19 | 22 | ||
| 20 | /// handles the encrpytion and storage of key material | 23 | /// handles the encrpytion and storage of key material |
| 21 | pub async fn launch( | 24 | pub async fn launch( |
| 25 | git_repo: &Repo, | ||
| 22 | nsec: &Option<String>, | 26 | nsec: &Option<String>, |
| 23 | password: &Option<String>, | 27 | password: &Option<String>, |
| 24 | #[cfg(test)] client: Option<&MockConnect>, | 28 | #[cfg(test)] client: Option<&MockConnect>, |
| 25 | #[cfg(not(test))] client: Option<&Client>, | 29 | #[cfg(not(test))] client: Option<&Client>, |
| 30 | change_user: bool, | ||
| 26 | ) -> Result<(nostr::Keys, UserRef)> { | 31 | ) -> Result<(nostr::Keys, UserRef)> { |
| 27 | // if nsec parameter | 32 | if let Ok(keys) = match get_keys_without_prompts(git_repo, nsec, password, change_user) { |
| 28 | let key = if let Some(nsec_unwrapped) = nsec { | 33 | Ok(keys) => Ok(keys), |
| 29 | // get key or fail without prompts | 34 | Err(error) => { |
| 30 | let key = nostr::Keys::from_str(nsec_unwrapped).context("invalid nsec parameter")?; | 35 | if error |
| 31 | 36 | .to_string() | |
| 32 | // if password, add user to enable password login in future | 37 | .eq("git config item nostr.nsec is an ncryptsec") |
| 33 | if password.is_some() { | 38 | { |
| 34 | UserManager::default() | 39 | println!( |
| 35 | .add(nsec, password) | 40 | "login as {}", |
| 36 | .context("could not store identity")?; | 41 | if let Ok(public_key) = PublicKey::from_bech32( |
| 37 | } else { | 42 | get_config_item(git_repo, "nostr.npub") |
| 38 | UserManager::default().add_user_to_config(key.public_key(), None, false)?; | 43 | .unwrap_or("unknown ncryptsec".to_string()), |
| 44 | ) { | ||
| 45 | if let Ok(user_ref) = get_user_details(&public_key, client).await { | ||
| 46 | user_ref.metadata.name | ||
| 47 | } else { | ||
| 48 | "unknown ncryptsec".to_string() | ||
| 49 | } | ||
| 50 | } else { | ||
| 51 | "unknown ncryptsec".to_string() | ||
| 52 | } | ||
| 53 | ); | ||
| 54 | loop { | ||
| 55 | // prompt for password | ||
| 56 | let password = Interactor::default() | ||
| 57 | .password(PromptPasswordParms::default().with_prompt("password")) | ||
| 58 | .context("failed to get password input from interactor.password")?; | ||
| 59 | if let Ok(keys) = get_keys_with_password(git_repo, &password) { | ||
| 60 | break Ok(keys); | ||
| 61 | } | ||
| 62 | println!("incorrect password"); | ||
| 63 | } | ||
| 64 | } else { | ||
| 65 | if nsec.is_some() { | ||
| 66 | bail!(error); | ||
| 67 | } | ||
| 68 | Err(error) | ||
| 69 | } | ||
| 39 | } | 70 | } |
| 40 | key | 71 | } { |
| 72 | // get user ref | ||
| 73 | let user_ref = get_user_details(&keys.public_key(), client).await?; | ||
| 74 | print_logged_in_as(&user_ref, client.is_none())?; | ||
| 75 | Ok((keys, user_ref)) | ||
| 41 | } else { | 76 | } else { |
| 42 | let cfg = ConfigManager | 77 | fresh_login(git_repo, client, change_user).await |
| 43 | .load() | 78 | } |
| 44 | .context("failed to load application config")?; | 79 | } |
| 45 | // if encrypted nsec present | 80 | |
| 46 | if cfg.users.last().is_some() && !cfg.users.last().unwrap().encrypted_key.is_empty() { | 81 | fn print_logged_in_as(user_ref: &UserRef, offline_mode: bool) -> Result<()> { |
| 47 | // unfortunately this line is unstable in rust: | 82 | if !offline_mode && user_ref.metadata.created_at.eq(&0) { |
| 48 | // if let Some(user) = cfg.users.last() && !user.encrypted_key.is_empty() { | 83 | println!("cannot find your account metadata (name, etc) on relays"); |
| 49 | let user = cfg.users.last().unwrap(); | 84 | } else if !offline_mode && user_ref.metadata.name.eq(&user_ref.public_key.to_bech32()?) { |
| 50 | let mut pass = if let Some(p) = password.clone() { | 85 | println!("cannot extract account name from account metadata..."); |
| 51 | p | 86 | } else if !offline_mode && user_ref.relays.created_at.eq(&0) { |
| 52 | } else { | 87 | println!( |
| 53 | println!("login as {}", &user.metadata.name); | 88 | "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." |
| 54 | Interactor::default() | 89 | ); |
| 55 | .password(PromptPasswordParms::default().with_prompt("password")) | 90 | } |
| 56 | .context("failed to get password input from interactor.password")? | 91 | println!("logged in as {}", user_ref.metadata.name); |
| 57 | }; | 92 | Ok(()) |
| 58 | 93 | } | |
| 59 | let key_result = Encryptor | 94 | |
| 60 | .decrypt_key(&user.encrypted_key, pass.as_str()) | 95 | fn get_keys_without_prompts( |
| 61 | .context("failed to decrypt key with provided password"); | 96 | git_repo: &Repo, |
| 62 | pass.zeroize(); | 97 | nsec: &Option<String>, |
| 63 | 98 | password: &Option<String>, | |
| 64 | key_result.context(format!("failed to log in as {}", &user.metadata.name))? | 99 | save_local: bool, |
| 100 | ) -> Result<nostr::Keys> { | ||
| 101 | if let Some(nsec) = nsec { | ||
| 102 | get_keys_from_nsec(git_repo, nsec, password, save_local) | ||
| 103 | } else if let Some(password) = password { | ||
| 104 | get_keys_with_password(git_repo, password) | ||
| 105 | } else if !save_local { | ||
| 106 | get_keys_with_git_config_nsec_without_prompts(git_repo) | ||
| 107 | } else { | ||
| 108 | bail!("user wants prompts to specify new keys") | ||
| 109 | } | ||
| 110 | } | ||
| 111 | |||
| 112 | fn get_keys_from_nsec( | ||
| 113 | git_repo: &Repo, | ||
| 114 | nsec: &String, | ||
| 115 | password: &Option<String>, | ||
| 116 | save_local: bool, | ||
| 117 | ) -> Result<nostr::Keys> { | ||
| 118 | #[allow(unused_assignments)] | ||
| 119 | let mut s = String::new(); | ||
| 120 | let keys = if nsec.contains("ncryptsec") { | ||
| 121 | s = nsec.to_string(); | ||
| 122 | decrypt_key( | ||
| 123 | nsec, | ||
| 124 | password | ||
| 125 | .clone() | ||
| 126 | .context("password must be supplied when using ncryptsec as nsec parameter")? | ||
| 127 | .as_str(), | ||
| 128 | ) | ||
| 129 | .context("failed to decrypt key with provided password") | ||
| 130 | .context("failed to decrypt ncryptsec supplied as nsec with password")? | ||
| 131 | } else { | ||
| 132 | s = nsec.to_string(); | ||
| 133 | nostr::Keys::from_str(nsec).context("invalid nsec parameter")? | ||
| 134 | }; | ||
| 135 | if save_local { | ||
| 136 | if let Some(password) = password { | ||
| 137 | s = encrypt_key(&keys, password)?; | ||
| 65 | } | 138 | } |
| 66 | // no encrypted nsec present | 139 | git_repo |
| 67 | else { | 140 | .save_git_config_item("nostr.nsec", &s, false) |
| 68 | // no nsec but password supplied | 141 | .context("failed to save encrypted nsec in local git config nostr.nsec")?; |
| 69 | if password.is_some() { | 142 | git_repo.save_git_config_item("nostr.npub", &keys.public_key().to_bech32()?, false)?; |
| 70 | bail!("no nsec available to decrypt with specified password"); | 143 | } |
| 144 | Ok(keys) | ||
| 145 | } | ||
| 146 | |||
| 147 | fn get_keys_with_password(git_repo: &Repo, password: &str) -> Result<nostr::Keys> { | ||
| 148 | decrypt_key( | ||
| 149 | &git_repo | ||
| 150 | .get_git_config_item("nostr.nsec", false) | ||
| 151 | .context("failed get git config")? | ||
| 152 | .context("git config item nostr.nsec doesn't exist so cannot decrypt it")?, | ||
| 153 | password, | ||
| 154 | ) | ||
| 155 | .context("failed to decrypt stored nsec key with provided password") | ||
| 156 | } | ||
| 157 | |||
| 158 | fn get_keys_with_git_config_nsec_without_prompts(git_repo: &Repo) -> Result<nostr::Keys> { | ||
| 159 | let nsec = &git_repo | ||
| 160 | .get_git_config_item("nostr.nsec", false) | ||
| 161 | .context("failed get git config")? | ||
| 162 | .context("git config item nostr.nsec doesn't exist")?; | ||
| 163 | if nsec.contains("ncryptsec") { | ||
| 164 | bail!("git config item nostr.nsec is an ncryptsec") | ||
| 165 | } | ||
| 166 | nostr::Keys::from_str(nsec).context("invalid nsec parameter") | ||
| 167 | } | ||
| 168 | |||
| 169 | async fn fresh_login( | ||
| 170 | git_repo: &Repo, | ||
| 171 | #[cfg(test)] client: Option<&MockConnect>, | ||
| 172 | #[cfg(not(test))] client: Option<&Client>, | ||
| 173 | always_save: bool, | ||
| 174 | ) -> Result<(nostr::Keys, UserRef)> { | ||
| 175 | // prompt for nsec | ||
| 176 | let mut prompt = "login with nsec"; | ||
| 177 | let keys = loop { | ||
| 178 | match nostr::Keys::from_str( | ||
| 179 | &Interactor::default() | ||
| 180 | .input(PromptInputParms::default().with_prompt(prompt)) | ||
| 181 | .context("failed to get nsec input from interactor")?, | ||
| 182 | ) { | ||
| 183 | Ok(key) => { | ||
| 184 | break key; | ||
| 185 | } | ||
| 186 | Err(_) => { | ||
| 187 | prompt = "invalid nsec. try again with nsec (or hex private key)"; | ||
| 71 | } | 188 | } |
| 72 | // otherwise add new user with nsec and password prompts | ||
| 73 | UserManager::default() | ||
| 74 | .add(nsec, password) | ||
| 75 | .context("failed to add user")? | ||
| 76 | } | 189 | } |
| 77 | }; | 190 | }; |
| 191 | // lookup profile | ||
| 192 | // save keys | ||
| 193 | if let Err(error) = save_keys(git_repo, &keys, always_save) { | ||
| 194 | println!("{error}"); | ||
| 195 | } | ||
| 196 | let user_ref = get_user_details(&keys.public_key(), client).await?; | ||
| 197 | print_logged_in_as(&user_ref, client.is_none())?; | ||
| 198 | Ok((keys, user_ref)) | ||
| 199 | } | ||
| 200 | |||
| 201 | fn save_keys(git_repo: &Repo, keys: &nostr::Keys, always_save: bool) -> Result<()> { | ||
| 202 | let store = always_save | ||
| 203 | || Interactor::default() | ||
| 204 | .confirm(PromptConfirmParms::default().with_prompt("save login details?"))?; | ||
| 205 | |||
| 206 | let global = !Interactor::default().confirm( | ||
| 207 | PromptConfirmParms::default() | ||
| 208 | .with_prompt("just for this repository?") | ||
| 209 | .with_default(false), | ||
| 210 | )?; | ||
| 211 | |||
| 212 | let encrypt = Interactor::default().confirm( | ||
| 213 | PromptConfirmParms::default() | ||
| 214 | .with_prompt("require password?") | ||
| 215 | .with_default(false), | ||
| 216 | )?; | ||
| 78 | 217 | ||
| 79 | // get user details | 218 | if store { |
| 80 | let user_ref = if let Some(client) = client { | 219 | let npub = keys.public_key().to_bech32()?; |
| 81 | get_user_details(&key.public_key(), client).await? | 220 | let nsec_string = if encrypt { |
| 221 | let password = Interactor::default() | ||
| 222 | .password( | ||
| 223 | PromptPasswordParms::default() | ||
| 224 | .with_prompt("encrypt with password") | ||
| 225 | .with_confirm(), | ||
| 226 | ) | ||
| 227 | .context("failed to get password input from interactor.password")?; | ||
| 228 | encrypt_key(keys, &password)? | ||
| 229 | } else { | ||
| 230 | keys.secret_key()?.to_bech32()? | ||
| 231 | }; | ||
| 232 | |||
| 233 | if let Err(error) = git_repo.save_git_config_item("nostr.nsec", &nsec_string, global) { | ||
| 234 | if global { | ||
| 235 | println!("failed to edit global git config instead"); | ||
| 236 | if Interactor::default().confirm( | ||
| 237 | PromptConfirmParms::default() | ||
| 238 | .with_prompt("save in repository git config?") | ||
| 239 | .with_default(true), | ||
| 240 | )? { | ||
| 241 | git_repo.save_git_config_item("nostr.nsec", &nsec_string, false)?; | ||
| 242 | git_repo.save_git_config_item("nostr.npub", &npub, false)?; | ||
| 243 | } | ||
| 244 | } else { | ||
| 245 | bail!(error) | ||
| 246 | } | ||
| 247 | } else { | ||
| 248 | git_repo.save_git_config_item("nostr.npub", &npub, global)?; | ||
| 249 | }; | ||
| 250 | }; | ||
| 251 | Ok(()) | ||
| 252 | } | ||
| 253 | |||
| 254 | fn get_config_item(git_repo: &Repo, name: &str) -> Result<String> { | ||
| 255 | git_repo | ||
| 256 | .get_git_config_item(name, false) | ||
| 257 | .context("failed get git config")? | ||
| 258 | .context(format!("git config item {name} doesn't exist")) | ||
| 259 | } | ||
| 260 | |||
| 261 | fn extract_user_metadata( | ||
| 262 | public_key: &nostr::PublicKey, | ||
| 263 | events: &[nostr::Event], | ||
| 264 | ) -> Result<UserMetadata> { | ||
| 265 | let event = events | ||
| 266 | .iter() | ||
| 267 | .filter(|e| e.kind.eq(&nostr::Kind::Metadata) && e.pubkey.eq(public_key)) | ||
| 268 | .max_by_key(|e| e.created_at); | ||
| 269 | |||
| 270 | let metadata: Option<nostr::Metadata> = if let Some(event) = event { | ||
| 271 | Some( | ||
| 272 | nostr::Metadata::from_json(event.content.clone()) | ||
| 273 | .context("metadata cannot be found in kind 0 event content")?, | ||
| 274 | ) | ||
| 82 | } else { | 275 | } else { |
| 83 | // this will get user details with name as npub | 276 | None |
| 84 | UserManager::default() | ||
| 85 | .get_user_from_cache(&key.public_key())? | ||
| 86 | .clone() | ||
| 87 | }; | 277 | }; |
| 88 | 278 | ||
| 89 | // print logged in | 279 | Ok(UserMetadata { |
| 90 | println!("logged in as {}", user_ref.metadata.name); | 280 | name: if let Some(metadata) = metadata { |
| 281 | if let Some(n) = metadata.name { | ||
| 282 | n | ||
| 283 | } else if let Some(n) = metadata.custom.get("displayName") { | ||
| 284 | // strip quote marks that custom.get() adds | ||
| 285 | let binding = n.to_string(); | ||
| 286 | let mut chars = binding.chars(); | ||
| 287 | chars.next(); | ||
| 288 | chars.next_back(); | ||
| 289 | chars.as_str().to_string() | ||
| 290 | } else if let Some(n) = metadata.display_name { | ||
| 291 | n | ||
| 292 | } else { | ||
| 293 | public_key.to_bech32()? | ||
| 294 | } | ||
| 295 | } else { | ||
| 296 | public_key.to_bech32()? | ||
| 297 | }, | ||
| 298 | created_at: if let Some(event) = event { | ||
| 299 | event.created_at.as_u64() | ||
| 300 | } else { | ||
| 301 | 0 | ||
| 302 | }, | ||
| 303 | }) | ||
| 304 | } | ||
| 91 | 305 | ||
| 92 | Ok((key, user_ref.clone())) | 306 | fn extract_user_relays(public_key: &nostr::PublicKey, events: &[nostr::Event]) -> UserRelays { |
| 307 | let event = events | ||
| 308 | .iter() | ||
| 309 | .filter(|e| e.kind.eq(&nostr::Kind::RelayList) && e.pubkey.eq(public_key)) | ||
| 310 | .max_by_key(|e| e.created_at); | ||
| 311 | |||
| 312 | UserRelays { | ||
| 313 | relays: if let Some(event) = event { | ||
| 314 | event | ||
| 315 | .tags | ||
| 316 | .iter() | ||
| 317 | .filter(|t| { | ||
| 318 | t.kind() | ||
| 319 | .eq(&nostr::TagKind::SingleLetter(SingleLetterTag::lowercase( | ||
| 320 | Alphabet::R, | ||
| 321 | ))) | ||
| 322 | }) | ||
| 323 | .map(|t| UserRelayRef { | ||
| 324 | url: t.as_vec()[1].clone(), | ||
| 325 | read: t.as_vec().len() == 2 || t.as_vec()[2].eq("read"), | ||
| 326 | write: t.as_vec().len() == 2 || t.as_vec()[2].eq("write"), | ||
| 327 | }) | ||
| 328 | .collect() | ||
| 329 | } else { | ||
| 330 | vec![] | ||
| 331 | }, | ||
| 332 | created_at: if let Some(event) = event { | ||
| 333 | event.created_at.as_u64() | ||
| 334 | } else { | ||
| 335 | 0 | ||
| 336 | }, | ||
| 337 | } | ||
| 93 | } | 338 | } |
| 94 | 339 | ||
| 95 | async fn get_user_details( | 340 | async fn get_user_details( |
| 96 | public_key: &PublicKey, | 341 | public_key: &PublicKey, |
| 97 | #[cfg(test)] client: &crate::client::MockConnect, | 342 | #[cfg(test)] client: Option<&crate::client::MockConnect>, |
| 98 | #[cfg(not(test))] client: &Client, | 343 | #[cfg(not(test))] client: Option<&Client>, |
| 99 | ) -> Result<UserRef> { | 344 | ) -> Result<UserRef> { |
| 100 | let term = console::Term::stdout(); | 345 | if client.is_some() { |
| 101 | term.write_line("searching for profile and relay updates...")?; | 346 | println!("searching for profile and relay updates..."); |
| 102 | let user_manager = UserManager::default(); | ||
| 103 | let user_ref = user_manager | ||
| 104 | .get_user( | ||
| 105 | client, | ||
| 106 | public_key, | ||
| 107 | // use cache for 3 minutes | ||
| 108 | 3 * 60, | ||
| 109 | ) | ||
| 110 | .await?; | ||
| 111 | term.clear_last_lines(1)?; | ||
| 112 | if user_ref.metadata.created_at.eq(&0) { | ||
| 113 | println!("cannot find your account metadata (name, etc) on relays",); | ||
| 114 | // TODO use secondary fallback list of relays. | ||
| 115 | // TODO better reporting of what relays were checked and what the user | ||
| 116 | // here is a starter: | ||
| 117 | // cannot find account details on relays: | ||
| 118 | // - purplepages.xyz | ||
| 119 | // - fallbackrelay1 | ||
| 120 | // - ... | ||
| 121 | // would you like to: | ||
| 122 | // [-] proceed anyway | ||
| 123 | // - add custom fallback relays | ||
| 124 | } else if user_ref.relays.created_at.eq(&0) { | ||
| 125 | println!( | ||
| 126 | "cannot find your relay list. consider using another nostr client to create one to enhance your nostr experience." | ||
| 127 | ); | ||
| 128 | // TODO better guidance on how to do this | ||
| 129 | } | 347 | } |
| 348 | let database = SQLiteDatabase::open(get_dirs()?.config_dir().join("cache.sqlite")).await?; | ||
| 349 | let mut events: Vec<nostr::Event> = vec![]; | ||
| 350 | let filters = vec![ | ||
| 351 | nostr::Filter::default() | ||
| 352 | .author(*public_key) | ||
| 353 | .kind(Kind::Metadata), | ||
| 354 | nostr::Filter::default() | ||
| 355 | .author(*public_key) | ||
| 356 | .kind(Kind::RelayList), | ||
| 357 | ]; | ||
| 358 | if let Ok(cached_events) = database.query(filters.clone(), Order::Asc).await { | ||
| 359 | for event in cached_events { | ||
| 360 | events.push(event); | ||
| 361 | } | ||
| 362 | } | ||
| 363 | let mut relays_to_search = if let Some(client) = client { | ||
| 364 | client.get_fallback_relays().clone() | ||
| 365 | } else { | ||
| 366 | vec![] | ||
| 367 | }; | ||
| 368 | let mut relays_searched = vec![]; | ||
| 369 | let user_ref = loop { | ||
| 370 | if let Some(client) = client { | ||
| 371 | for event in client | ||
| 372 | .get_events(relays_to_search.clone(), filters.clone()) | ||
| 373 | .await | ||
| 374 | .unwrap_or(vec![]) | ||
| 375 | { | ||
| 376 | let _ = database.save_event(&event).await; | ||
| 377 | events.push(event); | ||
| 378 | } | ||
| 379 | } | ||
| 380 | |||
| 381 | #[allow(clippy::clone_on_copy)] | ||
| 382 | let user_ref = UserRef { | ||
| 383 | public_key: public_key.clone(), | ||
| 384 | metadata: extract_user_metadata(public_key, &events)?, | ||
| 385 | relays: extract_user_relays(public_key, &events), | ||
| 386 | }; | ||
| 387 | |||
| 388 | if client.is_none() { | ||
| 389 | break user_ref; | ||
| 390 | } | ||
| 391 | for r in &relays_to_search { | ||
| 392 | relays_searched.push(r.clone()); | ||
| 393 | } | ||
| 394 | |||
| 395 | relays_to_search = user_ref | ||
| 396 | .relays | ||
| 397 | .write() | ||
| 398 | .iter() | ||
| 399 | .filter(|r| !relays_searched.iter().any(|or| r.eq(&or))) | ||
| 400 | .map(std::clone::Clone::clone) | ||
| 401 | .collect(); | ||
| 402 | if !relays_to_search.is_empty() { | ||
| 403 | continue; | ||
| 404 | } | ||
| 405 | break user_ref; | ||
| 406 | }; | ||
| 130 | Ok(user_ref) | 407 | Ok(user_ref) |
| 131 | } | 408 | } |