cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 1 | use std::sync::Arc; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 2 | #[cfg(feature = "sqlite")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 3 | use std::{collections::BTreeSet, path::PathBuf}; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 4 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 5 | use ruma::TransactionId; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 6 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 7 | use crate::{ |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 8 | AccountError, EncryptedSessionStore, OAuthLogin, PasswordLogin, PendingOAuthLogin, Restore, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 9 | Session, SessionRecord, SessionStore, SoftLogout, StoreConfig, account::homeserver_input, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 10 | engine::StoreLock, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 11 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 12 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 13 | /// every account one owner has, a store each under one root and one passphrase. whoever holds |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 14 | /// the set can resume any soft-logged-out device in it with just its password, and the device's |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 15 | /// store has its e2ee keys, so a set belongs to one person and never to a whole machine |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 16 | pub struct Accounts { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 17 | root: Root, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 18 | passphrase: String, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 19 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 20 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 21 | enum Root { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 22 | #[cfg(feature = "sqlite")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 23 | Directory(PathBuf), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 24 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 25 | Prefix(String), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 26 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 27 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 28 | /// one account's record and store |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 29 | #[derive(Clone)] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 30 | pub struct Account { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 31 | id: String, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 32 | sessions: Arc<EncryptedSessionStore>, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 33 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 34 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 35 | impl Account { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 36 | /// stable for the account's life, and safe in a cookie or a url |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 37 | #[must_use] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 38 | pub fn id(&self) -> &str { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 39 | &self.id |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 40 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 41 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 42 | /// for `Termination::retain`. a discarded account drops out of `Accounts::list` |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 43 | #[must_use] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 44 | pub fn sessions(&self) -> Arc<dyn SessionStore> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 45 | self.sessions.clone() |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 46 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 47 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 48 | pub async fn restore(&self) -> Result<Option<Restore>, AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 49 | Session::restore(self.sessions()).await |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 50 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 51 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 52 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 53 | impl std::fmt::Debug for Account { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 54 | fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 55 | formatter |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 56 | .debug_struct("Account") |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 57 | .field("id", &self.id) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 58 | .finish_non_exhaustive() |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 59 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 60 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 61 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 62 | enum Found { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 63 | Nobody, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 64 | SoftLoggedOut(Account, SoftLogout), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 65 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 66 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 67 | impl Accounts { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 68 | /// each account is a store directory under `root` with its record beside it |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 69 | #[cfg(feature = "sqlite")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 70 | #[must_use] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 71 | pub fn sqlite(root: impl Into<PathBuf>, passphrase: impl Into<String>) -> Self { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 72 | Self { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 73 | root: Root::Directory(root.into()), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 74 | passphrase: passphrase.into(), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 75 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 76 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 77 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 78 | /// each account is a store named `<prefix>::<id>`, listed in `<prefix>::macltok-accounts` |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 79 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 80 | #[must_use] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 81 | pub fn indexeddb(prefix: impl Into<String>, passphrase: impl Into<String>) -> Self { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 82 | Self { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 83 | root: Root::Prefix(prefix.into()), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 84 | passphrase: passphrase.into(), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 85 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 86 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 87 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 88 | /// `None` for anything `Account::id` couldn't have been |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 89 | #[must_use] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 90 | pub fn account(&self, id: &str) -> Option<Account> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 91 | let valid = |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 92 | !id.is_empty() && id.len() <= 64 && id.bytes().all(|b| b.is_ascii_alphanumeric()); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 93 | valid.then(|| self.open(id.to_owned())) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 94 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 95 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 96 | /// every account with a record. a store left without one, by a login that died halfway or a |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 97 | /// discard that couldn't finish, is deleted on the way past |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 98 | pub async fn list(&self) -> Result<Vec<Account>, AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 99 | let mut accounts = Vec::new(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 100 | for account in self.slots().await? { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 101 | match account.sessions.load().await { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 102 | Ok(None) => self.sweep(&account).await, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 103 | Ok(Some(_)) | Err(_) => accounts.push(account), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 104 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 105 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 106 | Ok(accounts) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 107 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 108 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 109 | /// a soft-logged-out account the login names is resumed as its own device, keeping its keys. |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 110 | /// anyone else gets a new account |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 111 | pub async fn login_password( |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 112 | &self, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 113 | login: PasswordLogin, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 114 | ) -> Result<(Account, Session), AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 115 | homeserver_input(&login.homeserver, login.allow_insecure_loopback)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 116 | let found = self |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 117 | .find(|record| record_names(record, &login.homeserver, &login.username)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 118 | .await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 119 | match found { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 120 | Found::SoftLoggedOut(account, soft) => { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 121 | let store_lock = account.sessions.store().lock().await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 122 | let login = PasswordLogin { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 123 | device_display_name: login.device_display_name, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 124 | ..soft.password_login(login.password) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 125 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 126 | let session = |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 127 | Session::login_password_locked(login, account.sessions(), store_lock).await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 128 | Ok((account, session)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 129 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 130 | Found::Nobody => { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 131 | let (account, store_lock) = self.fresh().await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 132 | let login = PasswordLogin { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 133 | device_id: None, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 134 | ..login |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 135 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 136 | match Session::login_password_locked(login, account.sessions(), store_lock).await { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 137 | Ok(session) => Ok((account, session)), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 138 | Err(error) => Err(self.abandon(&account, error).await), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 139 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 140 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 141 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 142 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 143 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 144 | /// resumes the soft-logged-out account whose device `login` names, as |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 145 | /// `SoftLogout::oauth_login` sets it. without one it's a new account, since which account an |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 146 | /// OAuth login is for isn't known until it finishes |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 147 | pub async fn begin_oauth( |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 148 | &self, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 149 | login: OAuthLogin, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 150 | ) -> Result<(Account, PendingOAuthLogin), AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 151 | homeserver_input(&login.homeserver, login.allow_insecure_loopback)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 152 | let device_id = login.device_id.clone(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 153 | let found = self |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 154 | .find(|record| { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 155 | record |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 156 | .soft_logout() |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 157 | .is_some_and(|soft| device_id.as_ref() == Some(&soft.device_id)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 158 | }) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 159 | .await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 160 | match found { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 161 | Found::SoftLoggedOut(account, soft) => { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 162 | let store_lock = account.sessions.store().lock().await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 163 | let login = OAuthLogin { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 164 | homeserver: soft.homeserver, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 165 | client_id: login.client_id.or(soft.client_id), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 166 | allow_insecure_loopback: true, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 167 | ..login |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 168 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 169 | let pending = |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 170 | Session::begin_oauth_locked(login, account.sessions(), store_lock).await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 171 | Ok((account, pending)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 172 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 173 | Found::Nobody => { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 174 | let (account, store_lock) = self.fresh().await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 175 | let login = OAuthLogin { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 176 | device_id: None, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 177 | ..login |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 178 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 179 | match Session::begin_oauth_locked(login, account.sessions(), store_lock).await { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 180 | Ok(pending) => Ok((account, pending)), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 181 | Err(error) => Err(self.abandon(&account, error).await), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 182 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 183 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 184 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 185 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 186 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 187 | /// a live login for the same person is refused rather than given a second device |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 188 | async fn find(&self, resumes: impl Fn(&SessionRecord) -> bool) -> Result<Found, AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 189 | for account in self.slots().await? { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 190 | let Ok(Some(record)) = account.sessions.load().await else { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 191 | continue; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 192 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 193 | if !resumes(&record) { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 194 | continue; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 195 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 196 | return match record.soft_logout() { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 197 | Some(soft) => Ok(Found::SoftLoggedOut(account, soft)), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 198 | None => Err(AccountError::AlreadyLoggedIn(record.user_id().clone())), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 199 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 200 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 201 | Ok(Found::Nobody) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 202 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 203 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 204 | /// locked before anything else can see it, so `list` never sweeps a login still under way |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 205 | async fn fresh(&self) -> Result<(Account, StoreLock), AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 206 | let account = self.open(TransactionId::new().to_string()); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 207 | let store_lock = account.sessions.store().lock().await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 208 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 209 | { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 210 | let Root::Prefix(prefix) = &self.root; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 211 | index::add(prefix, &account.id).await?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 212 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 213 | Ok((account, store_lock)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 214 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 215 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 216 | /// a failed first login leaves nothing behind. one whose record didn't save has a live device |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 217 | /// on the server, so that stays for `UnsavedSession::save` |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 218 | async fn abandon(&self, account: &Account, error: AccountError) -> AccountError { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 219 | if !matches!(error, AccountError::Unsaved { .. }) { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 220 | self.sweep(account).await; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 221 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 222 | error |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 223 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 224 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 225 | /// best effort, since whatever it leaves is found and swept again by the next `list` |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 226 | async fn sweep(&self, account: &Account) { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 227 | let Ok(_store_lock) = account.sessions.store().lock().await else { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 228 | return; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 229 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 230 | if !matches!(account.sessions.load().await, Ok(None)) { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 231 | return; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 232 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 233 | if account.sessions.discard().await.is_err() { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 234 | return; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 235 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 236 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 237 | { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 238 | let Root::Prefix(prefix) = &self.root; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 239 | let _ = index::remove(prefix, &account.id).await; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 240 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 241 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 242 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 243 | fn open(&self, id: String) -> Account { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 244 | let sessions = match &self.root { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 245 | #[cfg(feature = "sqlite")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 246 | Root::Directory(root) => EncryptedSessionStore::file( |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 247 | root.join(format!("{id}.session")), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 248 | StoreConfig::sqlite(root.join(&id), self.passphrase.clone()), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 249 | ), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 250 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 251 | Root::Prefix(prefix) => EncryptedSessionStore::indexeddb(StoreConfig::indexeddb( |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 252 | format!("{prefix}::{id}"), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 253 | self.passphrase.clone(), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 254 | )), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 255 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 256 | Account { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 257 | id, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 258 | sessions: Arc::new(sessions), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 259 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 260 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 261 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 262 | async fn slots(&self) -> Result<Vec<Account>, AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 263 | let ids = match &self.root { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 264 | #[cfg(feature = "sqlite")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 265 | Root::Directory(root) => directory_slots(root)?, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 266 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 267 | Root::Prefix(prefix) => index::list(prefix).await?, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 268 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 269 | Ok(ids.iter().filter_map(|id| self.account(id)).collect()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 270 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 271 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 272 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 273 | /// a localpart or a full user id names the same account, but only on the same server. the server |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 274 | /// is compared by name as well as by url, so a delegated `example.org` still finds the account |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 275 | /// discovery took to `matrix.example.org` |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 276 | fn record_names(record: &SessionRecord, homeserver: &str, username: &str) -> bool { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 277 | let user_id = record.user_id(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 278 | let username = username.trim(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 279 | let user = if username.starts_with('@') { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 280 | username.eq_ignore_ascii_case(user_id.as_str()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 281 | } else { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 282 | username.eq_ignore_ascii_case(user_id.localpart()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 283 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 284 | let typed = authority(homeserver); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 285 | user && (typed.eq_ignore_ascii_case(user_id.server_name().as_str()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 286 | || typed.eq_ignore_ascii_case(authority(record.homeserver()))) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 287 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 288 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 289 | fn authority(homeserver: &str) -> &str { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 290 | let homeserver = homeserver.trim(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 291 | let rest = homeserver |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 292 | .split_once("://") |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 293 | .map_or(homeserver, |(_, rest)| rest); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 294 | rest.split('/').next().unwrap_or(rest) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 295 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 296 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 297 | #[cfg(feature = "sqlite")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 298 | fn directory_slots(root: &std::path::Path) -> Result<BTreeSet<String>, AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 299 | use std::io::ErrorKind; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 300 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 301 | let entries = match std::fs::read_dir(root) { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 302 | Ok(entries) => entries, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 303 | Err(error) if error.kind() == ErrorKind::NotFound => return Ok(BTreeSet::new()), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 304 | Err(error) => return Err(crate::account::account_error("listing the accounts", error)), |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 305 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 306 | let mut ids = BTreeSet::new(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 307 | for entry in entries { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 308 | let entry = |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 309 | entry.map_err(|error| crate::account::account_error("listing the accounts", error))?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 310 | let name = entry.file_name(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 311 | let Some(name) = name.to_str() else { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 312 | continue; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 313 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 314 | if entry.path().is_dir() { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 315 | ids.insert(name.to_owned()); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 316 | } else if let Some(id) = name.strip_suffix(".session") { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 317 | ids.insert(id.to_owned()); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 318 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 319 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 320 | Ok(ids) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 321 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 322 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 323 | /// a key per account, so two tabs adding at once can't lose one to the other |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 324 | #[cfg(feature = "indexeddb")] |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 325 | mod index { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 326 | use std::collections::BTreeSet; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 327 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 328 | use indexed_db_futures::{ |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 329 | Build, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 330 | database::Database, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 331 | error::{Error, OpenDbError}, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 332 | prelude::*, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 333 | transaction::{Transaction, TransactionMode}, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 334 | }; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 335 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 336 | use crate::{AccountError, account::account_error}; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 337 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 338 | const ACCOUNTS: &str = "accounts"; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 339 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 340 | fn name(prefix: &str) -> String { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 341 | format!("{prefix}::macltok-accounts") |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 342 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 343 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 344 | async fn open(prefix: &str) -> Result<Database, OpenDbError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 345 | Database::open(&name(prefix)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 346 | .with_version(1u32) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 347 | .with_on_upgrade_needed(|_, transaction: &Transaction<'_>| -> Result<(), Error> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 348 | transaction.db().create_object_store(ACCOUNTS).build()?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 349 | Ok(()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 350 | }) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 351 | .build()? |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 352 | .await |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 353 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 354 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 355 | pub(super) async fn list(prefix: &str) -> Result<BTreeSet<String>, AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 356 | let failed = |error| account_error("listing the accounts", error); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 357 | let database = open(prefix).await.map_err(failed)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 358 | let ids = keys(&database).await; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 359 | database.close(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 360 | ids.map_err(|error| account_error("listing the accounts", error)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 361 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 362 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 363 | pub(super) async fn add(prefix: &str, id: &str) -> Result<(), AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 364 | let failed = |error| account_error("adding the account", error); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 365 | let database = open(prefix).await.map_err(failed)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 366 | let added = write(&database, |store| { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 367 | store.put(true).with_key(id).build()?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 368 | Ok(()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 369 | }) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 370 | .await; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 371 | database.close(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 372 | added.map_err(|error| account_error("adding the account", error)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 373 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 374 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 375 | pub(super) async fn remove(prefix: &str, id: &str) -> Result<(), AccountError> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 376 | let failed = |error| account_error("removing the account", error); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 377 | let database = open(prefix).await.map_err(failed)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 378 | let removed = write(&database, |store| { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 379 | store.delete(id).build()?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 380 | Ok(()) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 381 | }) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 382 | .await; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 383 | database.close(); |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 384 | removed.map_err(|error| account_error("removing the account", error)) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 385 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 386 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 387 | async fn keys(database: &Database) -> Result<BTreeSet<String>, Error> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 388 | let transaction = database |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 389 | .transaction(ACCOUNTS) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 390 | .with_mode(TransactionMode::Readonly) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 391 | .build()?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 392 | let store = transaction.object_store(ACCOUNTS)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 393 | store.get_all_keys::<String>().await?.collect() |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 394 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 395 | |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 396 | async fn write( |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 397 | database: &Database, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 398 | change: impl FnOnce(&indexed_db_futures::object_store::ObjectStore<'_>) -> Result<(), Error>, |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 399 | ) -> Result<(), Error> { |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 400 | let transaction = database |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 401 | .transaction(ACCOUNTS) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 402 | .with_mode(TransactionMode::Readwrite) |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 403 | .build()?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 404 | change(&transaction.object_store(ACCOUNTS)?)?; |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 405 | transaction.commit().await |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 406 | } |
cb0668d30c14 | atagen | 2026-10-08 11:34 UTC | 407 | } |