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