use crate::protos::auth::common::v1::PageRequest; use ::session::AuthedUser; use api::http::{Query, State}; use api::pagination::PageQuery; use api::rest::AppResponseData; use serde::{Deserialize, Serialize}; use sha2::{Digest, Sha256}; use time::OffsetDateTime; use utoipa::ToSchema; use uuid::Uuid; use crate::AuthService; use crate::errors::ApiError; use crate::share::account_helpers; const SESSION_PAGE_SIZE: i32 = 100; #[derive(Debug, Clone, Serialize, Deserialize, ToSchema)] pub struct DeviceDto { pub id: Uuid, pub device_name: Option, pub device_type: Option, pub os: Option, pub is_trusted: bool, pub first_seen_at: OffsetDateTime, pub last_seen_at: OffsetDateTime, } struct DeviceRow { id: Uuid, device_name: Option, device_type: Option, os: Option, first_seen_at: OffsetDateTime, last_seen_at: OffsetDateTime, } fn os_of(user_agent: &str) -> Option { let name = if user_agent.contains("Android") { "Android" } else if user_agent.contains("iPhone") || user_agent.contains("iPad") { "iOS" } else if user_agent.contains("Windows") { "Windows" } else if user_agent.contains("Macintosh") || user_agent.contains("Mac OS X") { "macOS" } else if user_agent.contains("Linux") { "Linux" } else { return None; }; Some(name.to_string()) } #[utoipa::path( get, path = "/me/devices", tag = "account", description = "按会话的用户代理归并出当前账号出现过的设备及其最近活跃时间。", params(PageQuery), responses( (status = 200, description = "devices derived from active sessions", body = AppResponseData>), (status = 401, description = "authentication required", body = AppResponseData>) ) )] pub async fn list_devices( State(service): State, AuthedUser(uid): AuthedUser, Query(query): Query, ) -> Result>, ApiError> { let listed = service .accounts .list_user_sessions( uid, false, true, PageRequest { page_size: SESSION_PAGE_SIZE, page_token: String::new(), }, ) .await .map_err(|error| ApiError::internal(error.to_string()))?; let mut rows: Vec = Vec::new(); for info in listed.sessions { let Some(created_at) = info .created_at .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)) else { continue; }; let last_seen_at = info .last_seen_at .and_then(|ts| account_helpers::datetime(ts.seconds, ts.nanos)) .unwrap_or(created_at); let id = { let user_agent = &info.user_agent; let digest = Sha256::digest(user_agent.as_bytes()); let mut bytes = [0u8; 16]; bytes.copy_from_slice(&digest[..16]); bytes[6] = (bytes[6] & 0x0f) | 0x40; bytes[8] = (bytes[8] & 0x3f) | 0x80; Uuid::from_bytes(bytes) }; match rows.iter_mut().find(|row| row.id == id) { Some(row) => { row.first_seen_at = row.first_seen_at.min(created_at); row.last_seen_at = row.last_seen_at.max(last_seen_at); } None => rows.push(DeviceRow { id, device_name: { let user_agent = &info.user_agent; let agent = 'inline_agent_of: { let name = if user_agent.contains("Edg/") { "Edge" } else if user_agent.contains("Firefox/") { "Firefox" } else if user_agent.contains("Chrome/") { "Chrome" } else if user_agent.contains("Safari/") { "Safari" } else if user_agent.contains("git/") { "Git" } else { break 'inline_agent_of None; }; Some(name.to_string()) }; let os = os_of(user_agent); match (agent, os) { (Some(agent), Some(os)) => Some(format!("{agent} on {os}")), (Some(agent), None) => Some(agent), (None, Some(os)) => Some(os), (None, None) => None, } }, device_type: { let user_agent = &info.user_agent; let kind = if user_agent.contains("iPad") { "tablet" } else if user_agent.contains("Android") || user_agent.contains("iPhone") { "mobile" } else if user_agent.is_empty() { "unknown" } else { "desktop" }; Some(kind.to_string()) }, os: os_of(&info.user_agent), first_seen_at: created_at, last_seen_at, }), } } rows.sort_by(|left, right| right.last_seen_at.cmp(&left.last_seen_at)); let (limit, offset) = query.bounds()?; let total = rows.len() as u64; let devices = rows .into_iter() .skip(offset as usize) .take(limit as usize) .map(|row| DeviceDto { id: row.id, device_name: row.device_name, device_type: row.device_type, os: row.os, is_trusted: false, first_seen_at: row.first_seen_at, last_seen_at: row.last_seen_at, }) .collect(); Ok(AppResponseData::ok(api::pagination::Page::new( devices, total, limit, offset, ))) }