use std::time::Duration; use std::{collections::HashMap, sync::Arc}; use futures::future::join_all; use rocket::{serde::json::Json, State}; use serde::{Deserialize, Serialize}; use serde_json::Value; use time::OffsetDateTime; use crate::api::auth::{WithSession, WithVerifiedFxaLogin, WithVerifiedSession}; use crate::api::{Empty, EMPTY}; use crate::db::DbConn; use crate::push::PushClient; use crate::utils::DeferAction; use crate::{ api::auth, auth::Authenticated, db::Db, types::{ Device, DeviceCommand, DeviceCommands, DeviceID, DevicePush, DeviceUpdate, OauthTokenID, SessionID, }, }; fn map_error(e: sqlx::Error) -> auth::Error { match &e { // not-null violations can presumably only be caused by bad parameters sqlx::Error::Database(de) if de.code().as_deref() == Some("23502") => { auth::Error::MissingParameter }, sqlx::Error::RowNotFound => auth::Error::UnknownDevice, _ => auth::Error::Other(anyhow!(e)), } } #[derive(Debug, Serialize, Deserialize, PartialEq)] #[allow(non_snake_case)] #[serde(deny_unknown_fields)] pub(crate) struct Info { isCurrentDevice: bool, id: DeviceID, #[serde(with = "time::serde::timestamp")] lastAccessTime: OffsetDateTime, name: String, r#type: String, pushCallback: Option, pushPublicKey: Option, pushAuthKey: Option, pushEndpointExpired: bool, availableCommands: HashMap, // NOTE location is optional per the spec, but fenix crashes if it isn't present location: Value, // MISSING lastAccessTimeFormatted // MISSING approximateLastAccessTime // MISSING approximateLastAccessTimeFormatted } fn device_to_json(current: Option<&DeviceID>, dev: Device) -> Info { let (pcb, ppk, pak) = match dev.push { Some(p) => (Some(p.callback), Some(p.public_key), Some(p.auth_key)), None => (None, None, None), }; Info { isCurrentDevice: Some(&dev.device_id) == current, id: dev.device_id, lastAccessTime: dev.last_active, name: dev.name, r#type: dev.type_, pushCallback: pcb, pushPublicKey: ppk, pushAuthKey: pak, pushEndpointExpired: dev.push_expired, availableCommands: dev.available_commands.into_map(), location: dev.location, } } #[derive(Serialize, Deserialize, PartialEq)] #[serde(transparent)] pub(crate) struct ListResp(Vec); #[get("/account/devices")] pub(crate) async fn devices( db: &DbConn, auth: Authenticated<(), WithVerifiedSession>, ) -> auth::Result { let devs = db.get_devices(&auth.context.uid).await?; Ok(Json(ListResp( devs.into_iter().map(|dev| device_to_json(auth.context.device_id.as_ref(), dev)).collect(), ))) } #[derive(Debug, Deserialize)] #[allow(non_snake_case)] #[serde(deny_unknown_fields)] pub(crate) struct DeviceReq { id: Option, name: Option, r#type: Option, pushCallback: Option, pushPublicKey: Option, pushAuthKey: Option, availableCommands: Option>, // present for legacy reasons, ignored #[allow(dead_code)] capabilities: Option>, location: Option, } #[post("/account/device", data = "")] pub(crate) async fn device( db: &DbConn, db_pool: &Db, defer: &DeferAction, client: &State>, // need to allow registrations to all sessions, otherwise the "now verified" // notification can't be sent data: Authenticated, ) -> auth::Result { let dev = data.body; if let (None, None, None) = (&dev.name, &dev.r#type, &dev.pushCallback) { return Err(auth::Error::MissingParameter); } let push = dev.pushCallback.map(|pcb| DevicePush { callback: pcb, public_key: dev.pushPublicKey.unwrap_or_default(), auth_key: dev.pushAuthKey.unwrap_or_default(), }); let (own_id, changed_id, notify) = match (dev.id, data.context.device_id) { (None, None) => { let new = DeviceID::random(); (Some(new), new, true) }, (None, Some(own)) => (Some(own), own, false), (Some(other), own) => (own, other, false), }; let result = db .change_device( &data.context.uid, &changed_id, DeviceUpdate { name: dev.name.as_ref().map(AsRef::as_ref), type_: dev.r#type.as_ref().map(AsRef::as_ref), push, available_commands: dev.availableCommands.map(DeviceCommands), location: dev.location, }, ) .await .map_err(map_error)?; if notify { db.set_session_device(&data.session, Some(&changed_id)).await?; match db.get_devices(&data.context.uid).await { Err(e) => warn!("device_connected push failed: {e}"), Ok(mut devs) => defer.spawn_after_success("api::auth/account/device(post)", { devs.retain(|d| d.device_id != changed_id); let (client, db) = (Arc::clone(client), db_pool.clone()); let name = result.name.clone(); async move { let db = db.begin().await?; client.device_connected(&db, &devs, &name).await; db.commit().await?; Ok(()) } }), }; } Ok(Json(device_to_json(own_id.as_ref(), result))) } #[derive(Debug, Deserialize, Serialize)] #[serde(deny_unknown_fields)] pub(crate) struct Command { target: DeviceID, command: String, payload: Value, ttl: Option, } #[derive(Debug, Deserialize, Serialize)] #[allow(non_snake_case)] #[serde(deny_unknown_fields)] pub(crate) struct InvokeResp { enqueued: bool, notified: bool, notifyError: Option, } // NOTE fenix doesn't register a push callback for some reason, so receiving tabs // always requires opening the tab share menu or tab list first. #[post("/account/devices/invoke_command", data = "")] pub(crate) async fn invoke( client: &State>, db: &DbConn, cmd: Authenticated, ) -> auth::Result { let sender = cmd.context.device_id; let dev = db.get_device(&cmd.context.uid, &cmd.body.target).await.map_err(map_error)?; if dev.available_commands.get(&cmd.body.command).is_none() { return Err(auth::Error::NoDeviceCommand); } let ttl = cmd.body.ttl.unwrap_or(30 * 86400).clamp(60, 30 * 86400); let idx = db .enqueue_command(&cmd.body.target, &sender, &cmd.body.command, &cmd.body.payload, ttl) .await?; let (notified, error) = client .command_received(db, &dev, &cmd.body.command, idx, &sender) .await .map_or_else(|e| (false, Some(e.to_string())), |_| (true, None)); Ok(Json(InvokeResp { enqueued: true, notified, notifyError: error })) } #[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub(crate) struct CommandData { command: String, payload: Value, sender: Option, } #[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub(crate) struct CommandsEntry { index: i64, data: CommandData, } #[derive(Debug, Serialize, Deserialize, PartialEq)] #[serde(deny_unknown_fields)] pub(crate) struct CommandsResp { index: i64, last: bool, messages: Vec, } fn map_command(c: DeviceCommand) -> CommandsEntry { CommandsEntry { index: c.index, data: CommandData { command: c.command, payload: c.payload, sender: c.sender }, } } #[get("/account/device/commands?&")] pub(crate) async fn commands( db: &DbConn, index: i64, limit: Option, auth: Authenticated<(), WithVerifiedSession>, ) -> auth::Result { let dev = auth.context.device_id.as_ref().ok_or(auth::Error::UnknownDevice)?; let (more, cmds) = db.get_commands(&auth.context.uid, dev, index, limit.unwrap_or(100).clamp(0, 100)).await?; Ok(Json(CommandsResp { index: cmds.iter().map(|c| c.index).max().unwrap_or(0), last: !more, messages: cmds.into_iter().map(map_command).collect(), })) } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] pub(crate) struct DestroyReq { id: DeviceID, } #[post("/account/device/destroy", data = "")] pub(crate) async fn destroy( db: &DbConn, db_pool: &Db, defer: &DeferAction, client: &State>, req: crate::auth::Authenticated, ) -> auth::Result { db.delete_device(&req.context.uid, &req.body.id).await.map_err(map_error)?; match db.get_devices(&req.context.uid).await { Err(e) => warn!("device_disconnected push failed: {e}"), Ok(devs) => defer.spawn_after_success("api::auth/account/device/destroy(post)", { let (client, db) = (Arc::clone(client), db_pool.clone()); async move { let db = db.begin().await?; client.device_disconnected(&db, &devs, &req.body.id).await; db.commit().await?; Ok(()) } }), }; Ok(EMPTY) } #[derive(Debug, Deserialize)] pub(crate) enum NotifyTarget { #[serde(rename = "all")] All, } #[derive(Debug, Deserialize)] pub(crate) enum NotifyEPAction { #[serde(rename = "accountVerify")] AccountVerify, } #[derive(Debug, Deserialize)] #[allow(non_snake_case)] #[serde(untagged, deny_unknown_fields)] pub(crate) enum NotifyReq { // deny_unknown_fields and flatten don't work together All { #[allow(dead_code)] to: NotifyTarget, _endpointAction: Option, excluded: Option>, payload: Value, TTL: Option, }, Some { to: Vec, _endpointAction: Option, payload: Value, TTL: Option, }, } #[post("/account/devices/notify", data = "")] pub(crate) async fn notify( db: &DbConn, client: &State>, req: Authenticated, ) -> auth::Result { let (to, payload, ttl) = match req.body { NotifyReq::All { excluded, payload, TTL: ttl, .. } => { let excluded = excluded.unwrap_or_default(); let mut devs = db.get_devices(&req.context.uid).await?; devs.retain(|d| !excluded.contains(&d.device_id)); (devs, payload, ttl) }, NotifyReq::Some { to, payload, TTL: ttl, .. } => { let to = join_all(to.iter().map(|id| db.get_device(&req.context.uid, id))) .await .into_iter() .collect::, _>>()?; (to, payload, ttl) }, }; client.push_any(db, &to, Duration::from_secs(ttl.unwrap_or(0).into()), payload).await; Ok(EMPTY) } #[derive(Debug, Serialize)] #[allow(non_snake_case)] pub(crate) struct AttachedClient { clientId: Option, deviceId: Option, sessionTokenId: Option, refreshTokenId: Option, isCurrentSession: bool, deviceType: Option, name: Option, #[serde(with = "time::serde::timestamp::option")] createdTime: Option, // MISSING createdTimeFormatted #[serde(with = "time::serde::timestamp::option")] lastAccessTime: Option, // MISSING lastAccessTimeFormatted // MISSING approximateLastAccessTime // MISSING approximateLastAccessTimeFormatted scope: Option, // MISSING location // MISSING userAgent // MISSING os } // MISSING filterIdleDevicesTimestamp #[get("/account/attached_clients")] pub(crate) async fn attached_clients( db: &DbConn, auth: Authenticated<(), WithVerifiedFxaLogin>, ) -> auth::Result> { let clients = db.get_attached_clients(&auth.context.uid).await?; Ok(Json( clients .into_iter() .map(|dev| AttachedClient { clientId: dev.client_id, deviceId: dev.device_id, refreshTokenId: dev.refresh_token_id, isCurrentSession: dev.session_token_id.as_ref() == Some(&auth.session), sessionTokenId: dev.session_token_id, deviceType: dev.device_type, name: dev.name, createdTime: dev.created_time, lastAccessTime: dev.last_access_time, scope: dev.scope, }) .collect::>(), )) } #[derive(Debug, Deserialize)] #[serde(deny_unknown_fields)] #[allow(non_snake_case)] pub(crate) struct DestroyAttachedClientReq { // NOTE should be used to verify token deletion, but since we allow only a fixed // number of clients that makes little sense. #[allow(dead_code)] clientId: Option, sessionTokenId: Option, refreshTokenId: Option, deviceId: Option, } #[post("/account/attached_client/destroy", data = "")] pub(crate) async fn destroy_attached_client( db: &DbConn, db_pool: &Db, defer: &DeferAction, client: &State>, req: Authenticated, ) -> auth::Result { // only one id may be given, otherwise deleting things properly is more work. if (req.body.sessionTokenId.is_some() as u32) + (req.body.refreshTokenId.is_some() as u32) + (req.body.deviceId.is_some() as u32) != 1 { return Err(auth::Error::InvalidParameter); } if let Some(dev) = req.body.deviceId { let devs = db.get_devices(&req.context.uid).await; db.delete_device(&req.context.uid, &dev).await?; match devs { Err(e) => warn!("device_disconnected push failed: {e}"), Ok(devs) => { defer.spawn_after_success("api::auth/account/attached_client/destroy(post)", { let (client, db) = (Arc::clone(client), db_pool.clone()); async move { let db = db.begin().await?; client.device_disconnected(&db, &devs, &dev).await; db.commit().await?; Ok(()) } }) }, }; } if let Some(id) = req.body.sessionTokenId { db.delete_session(&req.context.uid, &id).await?; } if let Some(id) = req.body.refreshTokenId { db.delete_refresh_token(&id).await?; } Ok(EMPTY) }