srdusr
aboutsummaryrefslogtreecommitdiffstats
path: root/crates/server/src
diff options
context:
space:
mode:
Diffstat (limited to 'crates/server/src')
-rw-r--r--crates/server/src/auth.rs30
-rw-r--r--crates/server/src/friends.rs14
2 files changed, 42 insertions, 2 deletions
diff --git a/crates/server/src/auth.rs b/crates/server/src/auth.rs
index d5294c4..c1402d5 100644
--- a/crates/server/src/auth.rs
+++ b/crates/server/src/auth.rs
@@ -250,12 +250,38 @@ pub async fn user_from_token(db: &sqlx::SqlitePool, token: &str) -> Option<UserV
/// Cookie session first (the browser's path), then an `Authorization:
/// Bearer <token>` header (the CLI's path) - lets a handler serve both
/// kinds of client without needing to know which one it's talking to.
+/// How stale a user's last_seen may be before they stop counting as online.
+pub const PRESENCE_WINDOW_SECS: i64 = 300;
+/// How often a single user's last_seen is actually written. Every
+/// authenticated request touching the row would be a write per request, which
+/// SQLite's single writer would not thank us for; once a minute is plenty for
+/// a five-minute window.
+const PRESENCE_WRITE_INTERVAL_SECS: i64 = 60;
+
+/// Records that this user is around. Cheap enough to call on every
+/// authenticated request: the WHERE clause skips the write unless the stored
+/// value is already stale, so it is one no-op UPDATE a minute per active user
+/// rather than one per request.
+async fn touch_last_seen(db: &sqlx::SqlitePool, user_id: &str) {
+ let now = OffsetDateTime::now_utc();
+ let cutoff = now - TimeDuration::seconds(PRESENCE_WRITE_INTERVAL_SECS);
+ let _ = sqlx::query(
+ "UPDATE users SET last_seen = ? WHERE id = ? AND (last_seen IS NULL OR last_seen < ?)",
+ )
+ .bind(format_timestamp(now))
+ .bind(user_id)
+ .bind(format_timestamp(cutoff))
+ .execute(db)
+ .await;
+}
+
pub async fn current_user_or_token(
db: &sqlx::SqlitePool,
jar: &CookieJar,
headers: &axum::http::HeaderMap,
) -> Option<UserView> {
if let Some(user) = current_user(db, jar).await {
+ touch_last_seen(db, &user.id).await;
return Some(user);
}
let token = headers
@@ -263,7 +289,9 @@ pub async fn current_user_or_token(
.to_str()
.ok()?
.strip_prefix("Bearer ")?;
- user_from_token(db, token).await
+ let user = user_from_token(db, token).await?;
+ touch_last_seen(db, &user.id).await;
+ Some(user)
}
async fn logout(State(state): State<Arc<AppState>>, jar: CookieJar) -> Result<impl IntoResponse, AppError> {
diff --git a/crates/server/src/friends.rs b/crates/server/src/friends.rs
index e720dbf..6621def 100644
--- a/crates/server/src/friends.rs
+++ b/crates/server/src/friends.rs
@@ -25,6 +25,9 @@ struct FriendEntry {
friendship_id: String,
user_id: String,
username: String,
+ /// Only meaningful for accepted friends; a pending request has no
+ /// presence worth reporting, so it is always false there.
+ online: bool,
}
#[derive(Debug, Serialize)]
@@ -39,6 +42,8 @@ fn row_to_entry(row: &sqlx::sqlite::SqliteRow, friendship_id_col: &str, user_id_
friendship_id: row.try_get(friendship_id_col).unwrap_or_default(),
user_id: row.try_get(user_id_col).unwrap_or_default(),
username: row.try_get(username_col).unwrap_or_default(),
+ // Absent on the pending-request queries, which do not select it.
+ online: row.try_get::<i64, _>("online").unwrap_or(0) != 0,
}
}
@@ -48,12 +53,19 @@ async fn list_friends(State(state): State<Arc<AppState>>, jar: CookieJar, header
// Accepted, in either direction - the "other" user is whichever side
// isn't us, so this always returns the friend's identity regardless of
// who originally sent the request.
+ // last_seen within the presence window marks a friend as online. Compared
+ // in SQL rather than in Rust so the list arrives ready to render.
+ let presence_cutoff = crate::auth::format_timestamp(
+ time::OffsetDateTime::now_utc() - time::Duration::seconds(crate::auth::PRESENCE_WINDOW_SECS),
+ );
let accepted = sqlx::query(
- "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username
+ "SELECT friendships.id as friendship_id, users.id as user_id, users.username as username,
+ (users.last_seen IS NOT NULL AND users.last_seen > ?) as online
FROM friendships
JOIN users ON users.id = CASE WHEN friendships.requester_id = ? THEN friendships.addressee_id ELSE friendships.requester_id END
WHERE friendships.status = 'accepted' AND (friendships.requester_id = ? OR friendships.addressee_id = ?)",
)
+ .bind(&presence_cutoff)
.bind(&user.id).bind(&user.id).bind(&user.id)
.fetch_all(&state.db)
.await?;