1#[cfg(feature = "session-screens")]
2mod language;
3#[cfg(feature = "session-screens")]
4mod screen;
5
6use std::time::Duration;
7
8use sofie::core::Instant;
9use sofie::db::{Db, Query, Uuid};
10use sofie::http::SessionRecord;
11use sofie::prelude::*;
12
13#[cfg(feature = "session-screens")]
14pub use language::language_routes;
15#[cfg(feature = "session-screens")]
16pub use screen::routes;
17
18use crate::AccountId;
19
20const DEVICE_CHARACTERS: usize = 200;
22const LAST_SEEN_PRECISION: Duration = Duration::from_secs(60);
24const PRUNE_MARGIN: Duration = Duration::from_hours(1);
27#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
28const RECENT_SIGN_IN: Duration = Duration::from_mins(10);
29
30#[derive(Aggregate)]
32pub struct AccountSession {
33 meta: Meta,
34 #[owner]
35 account: AccountId,
36 session: Uuid,
37 area: String,
38 principal: Option<Uuid>,
40 device: Option<String>,
41 last_seen_at: Instant,
42 ended_at: Option<Instant>,
43 expires_at: Option<Instant>,
45}
46
47impl AccountSession {
48 #[must_use]
49 pub fn area(&self) -> &str {
50 &self.area
51 }
52
53 #[must_use]
54 pub fn device(&self) -> Option<&str> {
55 self.device.as_deref()
56 }
57
58 #[must_use]
59 pub fn last_seen_at(&self) -> Instant {
60 self.last_seen_at
61 }
62
63 fn end(&mut self, now: Instant) {
64 self.ended_at.get_or_insert(now);
65 }
66
67 #[cfg(feature = "impersonation")]
68 pub(crate) fn resumable(&self, account: AccountId, area: &str) -> Option<Resumable> {
69 if self.account != account || self.area != area || self.ended_at.is_some() {
70 return None;
71 }
72 Some(Resumable {
73 session: self.session,
74 principal: self.principal.unwrap_or_else(|| account.uuid()),
75 signed_in_at: self.created_at()?,
76 })
77 }
78
79 #[cfg(feature = "impersonation")]
80 pub(crate) fn impersonated(&mut self, by: &Email, until: Instant) {
81 self.device = Some(format!("Impersonated by {}", by.as_str()));
82 self.expires_at = Some(until);
83 }
84}
85
86#[cfg(feature = "impersonation")]
87pub(crate) struct Resumable {
88 pub(crate) session: Uuid,
89 pub(crate) principal: Uuid,
90 pub(crate) signed_in_at: Instant,
91}
92
93pub(crate) fn started(
94 session: Uuid,
95 account: AccountId,
96 area: &str,
97 principal: Uuid,
98 user_agent: Option<&str>,
99 now: Instant,
100) -> Result<AccountSession> {
101 Ok(AccountSession {
102 meta: Meta::new()?,
103 account,
104 session,
105 area: area.to_owned(),
106 principal: Some(principal),
107 device: user_agent.map(device),
108 last_seen_at: now,
109 ended_at: None,
110 expires_at: None,
111 })
112}
113
114fn device(user_agent: &str) -> String {
115 user_agent
116 .chars()
117 .filter(|character| !sofie::core::misleads_display(*character))
118 .take(DEVICE_CHARACTERS)
119 .collect()
120}
121
122pub(crate) async fn record(
124 db: &Db,
125 session: Uuid,
126 seen: Option<sofie::core::Timestamp>,
127) -> Result<SessionRecord> {
128 let read = db.read();
129 let Some(mut record) = recorded(&read, session).await? else {
130 return Ok(SessionRecord::Unrecorded);
131 };
132 if record.ended_at.is_some() {
133 return Ok(SessionRecord::Ended);
134 }
135 #[cfg(feature = "impersonation")]
137 let impersonator = match record.expires_at {
138 Some(_) => match crate::impersonation::checked(&read, session).await? {
139 crate::impersonation::Checked::Absent => None,
140 crate::impersonation::Checked::By(impersonator) => Some(impersonator),
141 crate::impersonation::Checked::Beyond(impersonation) => {
142 return ending_impersonated(db, record, *impersonation).await;
143 }
144 },
145 None => None,
146 };
147 #[cfg(not(feature = "impersonation"))]
148 let impersonator = None;
149 let held = SessionRecord::Live {
150 account: record.account.uuid(),
151 expires_at: record.expires_at,
152 impersonator,
153 language: chosen_language(&read, record.account).await?,
154 };
155 let Some(seen) = seen.map(Instant::from) else {
156 return Ok(held);
157 };
158 if !held.is_live(seen) || seen < record.last_seen_at.checked_add(LAST_SEEN_PRECISION)? {
159 return Ok(held);
160 }
161 record.last_seen_at = seen;
162 match db.commit(&mut record).await {
163 Err(Error::Conflict) | Ok(()) => Ok(held),
165 Err(error) => Err(error),
166 }
167}
168
169#[cfg(feature = "impersonation")]
170async fn ending_impersonated(
171 db: &Db,
172 mut record: AccountSession,
173 mut impersonation: crate::impersonation::Impersonation,
174) -> Result<SessionRecord> {
175 let now = db.clock().now();
176 impersonation.end(now)?;
177 record.end(now);
178 match db.commit(&mut (record, impersonation)).await {
179 Err(Error::Conflict) | Ok(()) => Ok(SessionRecord::Ended),
181 Err(error) => Err(error),
182 }
183}
184
185#[derive(ReadModel)]
186#[read(from = crate::Account)]
187struct ChosenLanguage {
188 locale: Option<sofie::values::Locale>,
189}
190
191async fn chosen_language(read: &Read, account: AccountId) -> Result<Option<std::sync::Arc<str>>> {
192 let chosen = ChosenLanguage::query()
193 .where_(crate::Account::ID.eq(account))
194 .first(read)
195 .await?;
196 Ok(chosen
197 .and_then(|chosen| chosen.locale)
198 .map(|locale| locale.as_str().into()))
199}
200
201pub(crate) async fn prune(prune: &sofie::db::Prune, clock: &Clock) -> Result<()> {
203 let kept = sofie::auth::SessionClaims::ABSOLUTE_LIFETIME + PRUNE_MARGIN;
204 let before = clock.now().checked_sub(kept)?;
205 prune
206 .before(AccountSession::LAST_SEEN_AT, before)
207 .await
208 .map(drop)
209}
210
211pub async fn account_of(read: &Read, session: Uuid) -> Result<Option<AccountId>> {
213 let record = recorded(read, session).await?;
214 Ok(record
215 .filter(|record| record.ended_at.is_none())
216 .map(|record| record.account))
217}
218
219pub(crate) async fn recorded(read: &Read, session: Uuid) -> Result<Option<AccountSession>> {
220 AccountSession::query()
221 .where_(AccountSession::SESSION.eq(session))
222 .first(read)
223 .await
224}
225
226#[cfg(any(feature = "passkeys", feature = "oauth", feature = "totp"))]
227pub(crate) async fn signed_in_recently(read: &Read, session: Uuid, now: Instant) -> Result<bool> {
228 AccountSession::query()
229 .where_(AccountSession::SESSION.eq(session))
230 .where_(AccountSession::ENDED_AT.is_null())
231 .where_(AccountSession::CREATED_AT.gt(now.checked_sub(RECENT_SIGN_IN)?))
232 .exists(read)
233 .await
234}
235
236pub(crate) async fn ending_current(
237 read: &Read,
238 session: &Session,
239 clock: &Clock,
240) -> Result<Vec<AccountSession>> {
241 let Some(current) = session.current() else {
242 return Ok(Vec::new());
243 };
244 let mut ended: Vec<AccountSession> = recorded(read, current).await?.into_iter().collect();
245 for record in &mut ended {
246 record.end(clock.now());
247 }
248 Ok(ended)
249}
250
251#[derive(Default)]
253pub(crate) struct Impersonations {
254 #[cfg(feature = "impersonation")]
255 ended: Vec<crate::impersonation::Impersonation>,
256}
257
258impl sofie::db::Outcome for Impersonations {
259 fn plan(&self, plan: &mut sofie::db::Plan<'_>) -> Result<()> {
260 #[cfg(feature = "impersonation")]
261 self.ended.plan(plan)?;
262 #[cfg(not(feature = "impersonation"))]
263 let _ = plan;
264 Ok(())
265 }
266
267 fn committed(&mut self, stamp: sofie::db::Stamp) {
268 #[cfg(feature = "impersonation")]
269 self.ended.committed(stamp);
270 #[cfg(not(feature = "impersonation"))]
271 let _ = stamp;
272 }
273}
274
275pub(crate) async fn impersonations_in(
276 read: &Read,
277 ended: &[AccountSession],
278 now: Instant,
279) -> Result<Impersonations> {
280 #[cfg(feature = "impersonation")]
281 {
282 let sessions: Vec<Uuid> = ended.iter().map(|record| record.session).collect();
283 let ended = crate::impersonation::ending_in_sessions(read, &sessions, now).await?;
284 Ok(Impersonations { ended })
285 }
286 #[cfg(not(feature = "impersonation"))]
287 {
288 let _ = (read, ended, now);
289 Ok(Impersonations::default())
290 }
291}
292
293pub(crate) async fn ending_all(
295 read: &Read,
296 account: AccountId,
297 keep: Option<Uuid>,
298 now: Instant,
299) -> Result<Ended> {
300 #[cfg_attr(not(feature = "impersonation"), expect(unused_mut))]
301 let mut owned = AccountSession::ACCOUNT.eq(account);
302 #[cfg(feature = "impersonation")]
303 {
304 let started = crate::impersonation::sessions_started_by(read, account).await?;
305 owned = owned.or(AccountSession::SESSION.in_(started));
306 }
307 let mut which = AccountSession::query().where_(owned);
308 if let Some(keep) = keep {
309 which = which.where_(AccountSession::SESSION.ne(keep));
310 }
311 ending(read, which, now).await
312}
313
314pub async fn ending_in(read: &Read, account: AccountId, area: &str, now: Instant) -> Result<Ended> {
317 let which = AccountSession::query()
318 .where_(AccountSession::ACCOUNT.eq(account))
319 .where_(AccountSession::AREA.eq(area.to_owned()));
320 #[cfg(feature = "impersonation")]
321 let from: Vec<Uuid> = which
322 .clone()
323 .all(read)
324 .await?
325 .into_iter()
326 .map(|record| record.session)
327 .collect();
328 #[cfg_attr(not(feature = "impersonation"), expect(unused_mut))]
329 let mut ended = ending(read, which, now).await?;
330 #[cfg(feature = "impersonation")]
331 {
332 let started = crate::impersonation::sessions_started_from(read, account, &from).await?;
333 let impersonated = AccountSession::query().where_(AccountSession::SESSION.in_(started));
334 ended.add(ending(read, impersonated, now).await?);
335 }
336 Ok(ended)
337}
338
339pub async fn ending_as(
343 read: &Read,
344 accounts: &[AccountId],
345 area: &str,
346 principal: Uuid,
347 now: Instant,
348) -> Result<Ended> {
349 let signed_in = AccountSession::PRINCIPAL.is_null();
350 let which = AccountSession::query()
351 .where_(AccountSession::ACCOUNT.in_(accounts.iter().copied()))
352 .where_(AccountSession::AREA.eq(area.to_owned()))
353 .where_(signed_in.or(AccountSession::PRINCIPAL.eq(Some(principal))));
354 ending(read, which, now).await
355}
356
357pub struct Ended {
358 sessions: Vec<AccountSession>,
359 impersonations: Impersonations,
360}
361
362#[cfg(feature = "impersonation")]
363impl Ended {
364 fn add(&mut self, other: Self) {
365 self.sessions.extend(other.sessions);
366 self.impersonations.ended.extend(other.impersonations.ended);
367 }
368}
369
370impl sofie::db::Outcome for Ended {
371 fn plan(&self, plan: &mut sofie::db::Plan<'_>) -> Result<()> {
372 self.sessions.plan(plan)?;
373 self.impersonations.plan(plan)
374 }
375
376 fn committed(&mut self, stamp: sofie::db::Stamp) {
377 self.sessions.committed(stamp);
378 self.impersonations.committed(stamp);
379 }
380}
381
382async fn ending(read: &Read, which: Query<AccountSession>, now: Instant) -> Result<Ended> {
383 let mut sessions = which
384 .where_(AccountSession::ENDED_AT.is_null())
385 .all(read)
386 .await?;
387 for record in &mut sessions {
388 record.end(now);
389 }
390 let impersonations = impersonations_in(read, &sessions, now).await?;
391 Ok(Ended {
392 sessions,
393 impersonations,
394 })
395}
396
397#[cfg(feature = "account-data")]
398pub(crate) async fn exported(read: &Read, account: AccountId) -> Result<sofie::serde_json::Value> {
399 let sessions = AccountSession::query()
400 .where_(AccountSession::ACCOUNT.eq(account))
401 .all(read)
402 .await?;
403 Ok(sofie::serde_json::json!(
404 sessions
405 .iter()
406 .map(|session| sofie::serde_json::json!({
407 "area": session.area,
408 "device": session.device,
409 "last_seen_at": session.last_seen_at,
410 "ended_at": session.ended_at,
411 }))
412 .collect::<Vec<_>>()
413 ))
414}
415
416#[cfg(feature = "account-data")]
419pub(crate) async fn closing(read: &Read, account: AccountId, now: Instant) -> Result<Ended> {
420 let mut ended = ending_all(read, account, None, now).await?;
421 let earlier = AccountSession::query()
422 .where_(AccountSession::ACCOUNT.eq(account))
423 .where_(AccountSession::ENDED_AT.is_not_null())
424 .where_(AccountSession::DEVICE.is_not_null())
425 .all(read)
426 .await?;
427 ended.sessions.extend(earlier);
428 for session in &mut ended.sessions {
429 session.device = None;
430 }
431 Ok(ended)
432}