main
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<String>,
pub device_type: Option<String>,
pub os: Option<String>,
pub is_trusted: bool,
pub first_seen_at: OffsetDateTime,
pub last_seen_at: OffsetDateTime,
}
struct DeviceRow {
id: Uuid,
device_name: Option<String>,
device_type: Option<String>,
os: Option<String>,
first_seen_at: OffsetDateTime,
last_seen_at: OffsetDateTime,
}
fn os_of(user_agent: &str) -> Option<String> {
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<api::pagination::Page<DeviceDto>>),
(status = 401, description = "authentication required", body = AppResponseData<api::pagination::Page<DeviceDto>>)
)
)]
pub async fn list_devices(
State(service): State<AuthService>,
AuthedUser(uid): AuthedUser,
Query(query): Query<PageQuery>,
) -> Result<AppResponseData<api::pagination::Page<DeviceDto>>, 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<DeviceRow> = 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,
)))
}