Implement estate and marketplace services (#73)
This commit is contained in:
@@ -414,6 +414,12 @@ position, and queue flags. Packet/capability selection, state-before-event
|
|||||||
ordering, cancellation, limits, and live-operation safety are documented in
|
ordering, cancellation, limits, and live-operation safety are documented in
|
||||||
[`docs/land.md`](docs/land.md).
|
[`docs/land.md`](docs/land.md).
|
||||||
|
|
||||||
|
Estate administration, experience preferences, marketplace listings, and
|
||||||
|
abuse reporting now use native owner-message, capability, event, cache, and
|
||||||
|
legacy-packet paths. Exact schedules, list deltas, folder roles, report fields,
|
||||||
|
cancellation, and explicit live-operation gates are documented in
|
||||||
|
[`docs/world-services.md`](docs/world-services.md).
|
||||||
|
|
||||||
Grid and directory discovery now use native LLUDP map and search requests,
|
Grid and directory discovery now use native LLUDP map and search requests,
|
||||||
correlated bounded reply events, expiring region indexes, coarse-location
|
correlated bounded reply events, expiring region indexes, coarse-location
|
||||||
deltas, and exact region-handle math. SLURL parsing covers location and
|
deltas, and exact region-handle math. SLURL parsing covers location and
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ Generated by `python3 tools/generate_api_shims.py`; do not edit by hand.
|
|||||||
|
|
||||||
| Assembly | Types | Members | Status |
|
| Assembly | Types | Members | Status |
|
||||||
|---|---:|---:|---|
|
|---|---:|---:|---|
|
||||||
| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 351 types / 16,636 members; remaining surface is callable failure-only shims |
|
| `LibreMetaverse` | 2,711 | 27,281 | native implementation: 369 types / 16,774 members; remaining surface is callable failure-only shims |
|
||||||
| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain |
|
| `LibreMetaverse.Imaging.Abstractions` | 3 | 20 | native implementation: 3 types / 20 members; no generated shims remain |
|
||||||
| `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
|
| `LibreMetaverse.Imaging.Skia` | 1 | 3 | native implementation: 1 type / 3 members; no generated shims remain |
|
||||||
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |
|
| `LibreMetaverse.LslTools` | 164 | 768 | callable failure-only shim |
|
||||||
|
|||||||
@@ -150,6 +150,38 @@ impl std::fmt::Debug for AssetManager {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl AssetManager {
|
impl AssetManager {
|
||||||
|
pub(crate) fn native_stage_estate_upload(&self, data: Vec<u8>) -> Result<UUID, Error> {
|
||||||
|
if data.is_empty() || data.len() > crate::asset_models::MAX_ASSET_BYTES {
|
||||||
|
return Err(Error::Argument);
|
||||||
|
}
|
||||||
|
let id = UUID::random()?;
|
||||||
|
let upload = Arc::new(Mutex::new(UploadState {
|
||||||
|
asset_id: id,
|
||||||
|
data,
|
||||||
|
packet_num: 0,
|
||||||
|
transaction_id: id,
|
||||||
|
transferred: 0,
|
||||||
|
type_: AssetType::Unknown,
|
||||||
|
xfer_id: 0,
|
||||||
|
confirmation: None,
|
||||||
|
}));
|
||||||
|
let mut pending = mutex(&self.inner.pending_upload);
|
||||||
|
if pending.is_some() {
|
||||||
|
return Err(Error::InvalidOperation);
|
||||||
|
}
|
||||||
|
*pending = Some(upload);
|
||||||
|
Ok(id)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn native_cancel_staged_estate_upload(&self, id: UUID) {
|
||||||
|
let mut pending = mutex(&self.inner.pending_upload);
|
||||||
|
if pending
|
||||||
|
.as_ref()
|
||||||
|
.is_some_and(|upload| mutex(upload).transaction_id == id)
|
||||||
|
{
|
||||||
|
pending.take();
|
||||||
|
}
|
||||||
|
}
|
||||||
pub(crate) fn native_from_inner(inner: Arc<AssetManagerInner>) -> Result<Self, Error> {
|
pub(crate) fn native_from_inner(inner: Arc<AssetManagerInner>) -> Result<Self, Error> {
|
||||||
inner.client.upgrade().ok_or(Error::InvalidOperation)?;
|
inner.client.upgrade().ok_or(Error::InvalidOperation)?;
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
|
|||||||
@@ -120,6 +120,7 @@ struct ClientRuntime {
|
|||||||
agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>,
|
agent_manager: Mutex<std::sync::Weak<crate::agent_manager::AgentManagerInner>>,
|
||||||
object_manager: Mutex<Option<Arc<crate::object_manager::ObjectManagerInner>>>,
|
object_manager: Mutex<Option<Arc<crate::object_manager::ObjectManagerInner>>>,
|
||||||
environment_manager: Mutex<Option<Arc<crate::environment_manager::EnvironmentManagerInner>>>,
|
environment_manager: Mutex<Option<Arc<crate::environment_manager::EnvironmentManagerInner>>>,
|
||||||
|
estate_tools: Mutex<Option<Arc<crate::estate_tools::EstateToolsInner>>>,
|
||||||
terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>,
|
terrain_manager: Mutex<Option<Arc<crate::terrain_manager::TerrainManagerInner>>>,
|
||||||
sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>,
|
sound_manager: Mutex<Option<Arc<crate::sound_manager::SoundManagerInner>>>,
|
||||||
parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>,
|
parcel_manager: Mutex<Option<Arc<crate::parcel_manager::ParcelManagerInner>>>,
|
||||||
@@ -164,6 +165,7 @@ impl ClientRuntime {
|
|||||||
agent_manager: Mutex::new(std::sync::Weak::new()),
|
agent_manager: Mutex::new(std::sync::Weak::new()),
|
||||||
object_manager: Mutex::new(None),
|
object_manager: Mutex::new(None),
|
||||||
environment_manager: Mutex::new(None),
|
environment_manager: Mutex::new(None),
|
||||||
|
estate_tools: Mutex::new(None),
|
||||||
terrain_manager: Mutex::new(None),
|
terrain_manager: Mutex::new(None),
|
||||||
sound_manager: Mutex::new(None),
|
sound_manager: Mutex::new(None),
|
||||||
parcel_manager: Mutex::new(None),
|
parcel_manager: Mutex::new(None),
|
||||||
@@ -760,6 +762,44 @@ impl GridClient {
|
|||||||
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.inner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub(crate) fn install_estate_tools_inner(
|
||||||
|
&self,
|
||||||
|
candidate: Arc<crate::estate_tools::EstateToolsInner>,
|
||||||
|
) -> Arc<crate::estate_tools::EstateToolsInner> {
|
||||||
|
let mut cached = self
|
||||||
|
.runtime
|
||||||
|
.estate_tools
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner);
|
||||||
|
if let Some(inner) = cached.as_ref() {
|
||||||
|
return Arc::clone(inner);
|
||||||
|
}
|
||||||
|
*cached = Some(Arc::clone(&candidate));
|
||||||
|
candidate
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn native_estate(&self) -> Result<crate::EstateTools, crate::Error> {
|
||||||
|
if let Some(inner) = self
|
||||||
|
.runtime
|
||||||
|
.estate_tools
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
.clone()
|
||||||
|
{
|
||||||
|
return Ok(crate::estate_tools::EstateTools::native_from_inner(inner));
|
||||||
|
}
|
||||||
|
crate::estate_tools::EstateTools::native_new(self.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[allow(clippy::needless_pass_by_value)]
|
||||||
|
pub(crate) fn native_set_estate(&self, value: crate::EstateTools) {
|
||||||
|
*self
|
||||||
|
.runtime
|
||||||
|
.estate_tools
|
||||||
|
.lock()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner) = Some(value.native_inner());
|
||||||
|
}
|
||||||
|
|
||||||
pub(crate) fn cached_terrain_manager_inner(
|
pub(crate) fn cached_terrain_manager_inner(
|
||||||
&self,
|
&self,
|
||||||
) -> Option<Arc<crate::terrain_manager::TerrainManagerInner>> {
|
) -> Option<Arc<crate::terrain_manager::TerrainManagerInner>> {
|
||||||
|
|||||||
1489
crates/libremetaverse/src/estate_tools.rs
Normal file
1489
crates/libremetaverse/src/estate_tools.rs
Normal file
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -27,6 +27,7 @@ mod client_core;
|
|||||||
mod current_outfit;
|
mod current_outfit;
|
||||||
mod download_manager;
|
mod download_manager;
|
||||||
mod environment_manager;
|
mod environment_manager;
|
||||||
|
mod estate_tools;
|
||||||
mod event_queue;
|
mod event_queue;
|
||||||
mod friends_manager;
|
mod friends_manager;
|
||||||
mod gesture;
|
mod gesture;
|
||||||
@@ -42,6 +43,8 @@ mod generated;
|
|||||||
mod genepool_catalog;
|
mod genepool_catalog;
|
||||||
mod j2k;
|
mod j2k;
|
||||||
mod login;
|
mod login;
|
||||||
|
#[path = "marketplace.rs"]
|
||||||
|
mod marketplace_runtime;
|
||||||
mod message_codec;
|
mod message_codec;
|
||||||
mod message_decoder;
|
mod message_decoder;
|
||||||
mod network_manager;
|
mod network_manager;
|
||||||
@@ -67,6 +70,7 @@ mod terrain_codec;
|
|||||||
mod terrain_manager;
|
mod terrain_manager;
|
||||||
mod transfers;
|
mod transfers;
|
||||||
mod udp_transport;
|
mod udp_transport;
|
||||||
|
mod user_report;
|
||||||
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
|
#[rustfmt::skip] // Deterministic machine output is formatted by the pinned generator.
|
||||||
mod visual_catalog;
|
mod visual_catalog;
|
||||||
|
|
||||||
@@ -156,29 +160,15 @@ impl InventoryAISClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[allow(dead_code, clippy::unused_self)]
|
#[allow(dead_code, clippy::needless_pass_by_value, clippy::unnecessary_wraps)]
|
||||||
impl EstateTools {
|
|
||||||
fn estate_owner_message_handler(
|
|
||||||
&self,
|
|
||||||
_packet: packets::EstateOwnerMessagePacket,
|
|
||||||
_simulator: Simulator,
|
|
||||||
) -> Result<(), Error> {
|
|
||||||
libremetaverse_types::not_implemented(
|
|
||||||
"M:LibreMetaverse.EstateTools.EstateOwnerMessageHandler(System.Object,LibreMetaverse.PacketReceivedEventArgs)",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[allow(dead_code, clippy::unused_self)]
|
|
||||||
impl NetworkManager {
|
impl NetworkManager {
|
||||||
fn raise_sim_console_response(
|
fn raise_sim_console_response(
|
||||||
&self,
|
&self,
|
||||||
_message: messages::linden::SimConsoleResponseMessage,
|
message: messages::linden::SimConsoleResponseMessage,
|
||||||
_simulator: Simulator,
|
simulator: Simulator,
|
||||||
) -> Result<(), Error> {
|
) -> Result<(), Error> {
|
||||||
libremetaverse_types::not_implemented(
|
self.dispatch_caps_event("SimConsoleResponse", &message, simulator);
|
||||||
"F:LibreMetaverse.NetworkManager.CapsEvents.SimConsoleResponse",
|
Ok(())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -207,6 +197,14 @@ pub use udp_transport::{
|
|||||||
AgentThrottleSender, UdpPacketHandler, UdpThrottleCategory, UdpTransportConfig,
|
AgentThrottleSender, UdpPacketHandler, UdpThrottleCategory, UdpTransportConfig,
|
||||||
UdpTransportError, UdpTransportStats,
|
UdpTransportError, UdpTransportStats,
|
||||||
};
|
};
|
||||||
|
pub use user_report::{UserReport, UserReportService, UserReportType};
|
||||||
|
|
||||||
|
pub(crate) fn is_offline_fixture_uri(uri: &libremetaverse_types::compat::Uri) -> bool {
|
||||||
|
reqwest::Url::parse(&uri.0)
|
||||||
|
.ok()
|
||||||
|
.and_then(|url| url.host_str().map(str::to_owned))
|
||||||
|
.is_some_and(|host| host == "invalid" || host.ends_with(".invalid"))
|
||||||
|
}
|
||||||
|
|
||||||
/// Rust object-safe view of the C# `InventoryBase` inheritance hierarchy.
|
/// Rust object-safe view of the C# `InventoryBase` inheritance hierarchy.
|
||||||
pub trait InventoryObjectClass: std::any::Any {
|
pub trait InventoryObjectClass: std::any::Any {
|
||||||
|
|||||||
708
crates/libremetaverse/src/marketplace.rs
Normal file
708
crates/libremetaverse/src/marketplace.rs
Normal file
@@ -0,0 +1,708 @@
|
|||||||
|
//! Offline marketplace folder classification and capability-backed listing state.
|
||||||
|
|
||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::must_use_candidate)]
|
||||||
|
#![allow(clippy::needless_pass_by_value)]
|
||||||
|
|
||||||
|
use crate::agent_manager::EventRegistry;
|
||||||
|
use crate::marketplace::{
|
||||||
|
MarketplaceFolderRole, MarketplaceListingStatus, MarketplaceValidationFlags,
|
||||||
|
};
|
||||||
|
use crate::{Error, GridClient, Inventory, InventoryFolder};
|
||||||
|
use libremetaverse_structured_data::{OSD, OSDParser};
|
||||||
|
use libremetaverse_types::compat::{
|
||||||
|
CancellationToken, EventHandler, ExternalError, HttpResponse, Subscription, Uri,
|
||||||
|
};
|
||||||
|
use libremetaverse_types::{FolderType, UUID};
|
||||||
|
use serde_json::{Value, json};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
use std::sync::{Arc, RwLock};
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
const JSON_CONTENT_TYPE: &str = "application/json";
|
||||||
|
|
||||||
|
fn read<T>(value: &RwLock<T>) -> std::sync::RwLockReadGuard<'_, T> {
|
||||||
|
value
|
||||||
|
.read()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn write<T>(value: &RwLock<T>) -> std::sync::RwLockWriteGuard<'_, T> {
|
||||||
|
value
|
||||||
|
.write()
|
||||||
|
.unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn folder_at(inventory: &Inventory, id: UUID) -> Option<InventoryFolder> {
|
||||||
|
inventory
|
||||||
|
.get_node_or_default(id)
|
||||||
|
.ok()
|
||||||
|
.flatten()
|
||||||
|
.and_then(|node| node.data())
|
||||||
|
.and_then(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn child_folders(inventory: &Inventory, parent: UUID) -> Vec<InventoryFolder> {
|
||||||
|
inventory
|
||||||
|
.get_contents_with_uuid(parent)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.into_iter()
|
||||||
|
.filter_map(|value| value.as_any().downcast_ref::<InventoryFolder>().cloned())
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pure inventory-tree decisions used by marketplace workflows.
|
||||||
|
pub struct MarketplaceFolderClassifier;
|
||||||
|
|
||||||
|
impl MarketplaceFolderClassifier {
|
||||||
|
pub fn get_listings_root(inventory: &Inventory) -> Result<UUID, Error> {
|
||||||
|
Ok(inventory
|
||||||
|
.find_folder_for_type(FolderType::MarketplaceListings)
|
||||||
|
.unwrap_or_else(UUID::zero))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_role(
|
||||||
|
folder_id: UUID,
|
||||||
|
inventory: &Inventory,
|
||||||
|
) -> Result<MarketplaceFolderRole, Error> {
|
||||||
|
let Some(folder) = folder_at(inventory, folder_id) else {
|
||||||
|
return Ok(MarketplaceFolderRole::None);
|
||||||
|
};
|
||||||
|
if folder.preferred_type() == FolderType::MarketplaceListings {
|
||||||
|
return Ok(MarketplaceFolderRole::ListingsRoot);
|
||||||
|
}
|
||||||
|
let Some(parent) = folder_at(inventory, folder.base.parent_uuid()) else {
|
||||||
|
return Ok(MarketplaceFolderRole::None);
|
||||||
|
};
|
||||||
|
if parent.preferred_type() == FolderType::MarketplaceListings {
|
||||||
|
return Ok(MarketplaceFolderRole::Listing);
|
||||||
|
}
|
||||||
|
let parent_role = Self::get_role(parent.base.uuid(), inventory)?;
|
||||||
|
if folder.preferred_type() == FolderType::MarketplaceVersion
|
||||||
|
&& parent_role == MarketplaceFolderRole::Listing
|
||||||
|
{
|
||||||
|
return Ok(MarketplaceFolderRole::Version);
|
||||||
|
}
|
||||||
|
if folder.preferred_type() == FolderType::MarketplaceStock
|
||||||
|
&& parent_role == MarketplaceFolderRole::Version
|
||||||
|
{
|
||||||
|
return Ok(MarketplaceFolderRole::Stock);
|
||||||
|
}
|
||||||
|
if parent_role == MarketplaceFolderRole::Version {
|
||||||
|
return Ok(MarketplaceFolderRole::Content);
|
||||||
|
}
|
||||||
|
Ok(MarketplaceFolderRole::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn is_marketplace_folder(folder_id: UUID, inventory: &Inventory) -> Result<bool, Error> {
|
||||||
|
Ok(Self::get_role(folder_id, inventory)? != MarketplaceFolderRole::None)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_listing_folder(folder_id: UUID, inventory: &Inventory) -> Result<UUID, Error> {
|
||||||
|
let mut current = folder_id;
|
||||||
|
for _ in 0..512 {
|
||||||
|
match Self::get_role(current, inventory)? {
|
||||||
|
MarketplaceFolderRole::Listing => return Ok(current),
|
||||||
|
MarketplaceFolderRole::ListingsRoot | MarketplaceFolderRole::None => {
|
||||||
|
return Ok(UUID::zero());
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
let Some(folder) = folder_at(inventory, current) else {
|
||||||
|
return Ok(UUID::zero());
|
||||||
|
};
|
||||||
|
current = folder.base.parent_uuid();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(UUID::zero())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_version_folder(listing: UUID, inventory: &Inventory) -> Result<UUID, Error> {
|
||||||
|
if Self::get_role(listing, inventory)? != MarketplaceFolderRole::Listing {
|
||||||
|
return Ok(UUID::zero());
|
||||||
|
}
|
||||||
|
Ok(child_folders(inventory, listing)
|
||||||
|
.into_iter()
|
||||||
|
.find(|folder| folder.preferred_type() == FolderType::MarketplaceVersion)
|
||||||
|
.map_or_else(UUID::zero, |folder| folder.base.uuid()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_stock_count(version: UUID, inventory: &Inventory) -> Result<i32, Error> {
|
||||||
|
if version == UUID::zero() {
|
||||||
|
return Ok(0);
|
||||||
|
}
|
||||||
|
let count = inventory
|
||||||
|
.get_contents_with_uuid(version)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.iter()
|
||||||
|
.filter(|value| value.as_any().downcast_ref::<InventoryFolder>().is_none())
|
||||||
|
.count();
|
||||||
|
Ok(i32::try_from(count).unwrap_or(i32::MAX))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_all_listing_folder_ids(inventory: &Inventory) -> Result<Vec<UUID>, Error> {
|
||||||
|
let root = Self::get_listings_root(inventory)?;
|
||||||
|
if root == UUID::zero() {
|
||||||
|
return Ok(Vec::new());
|
||||||
|
}
|
||||||
|
Ok(child_folders(inventory, root)
|
||||||
|
.into_iter()
|
||||||
|
.map(|folder| folder.base.uuid())
|
||||||
|
.collect())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn validate_listing(
|
||||||
|
listing: UUID,
|
||||||
|
inventory: &Inventory,
|
||||||
|
) -> Result<MarketplaceValidationFlags, Error> {
|
||||||
|
if Self::get_role(listing, inventory)? != MarketplaceFolderRole::Listing {
|
||||||
|
return Ok(MarketplaceValidationFlags::INVALID_STRUCTURE);
|
||||||
|
}
|
||||||
|
let versions: Vec<_> = child_folders(inventory, listing)
|
||||||
|
.into_iter()
|
||||||
|
.filter(|folder| folder.preferred_type() == FolderType::MarketplaceVersion)
|
||||||
|
.collect();
|
||||||
|
match versions.as_slice() {
|
||||||
|
[] => Ok(MarketplaceValidationFlags::MISSING_VERSION_FOLDER),
|
||||||
|
[version] if Self::get_stock_count(version.base.uuid(), inventory)? == 0 => {
|
||||||
|
Ok(MarketplaceValidationFlags::EMPTY_LISTING)
|
||||||
|
}
|
||||||
|
[_] => Ok(MarketplaceValidationFlags::VALID),
|
||||||
|
_ => Ok(MarketplaceValidationFlags::MULTIPLE_VERSION_FOLDERS),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct MarketplaceListing {
|
||||||
|
edit_url: Option<String>,
|
||||||
|
last_sync_utc: SystemTime,
|
||||||
|
listing_folder_uuid: UUID,
|
||||||
|
listing_id: i32,
|
||||||
|
status: MarketplaceListingStatus,
|
||||||
|
stock_count: i32,
|
||||||
|
version_folder_uuid: UUID,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl MarketplaceListing {
|
||||||
|
pub fn new() -> Result<Self, Error> {
|
||||||
|
Ok(Self {
|
||||||
|
edit_url: None,
|
||||||
|
last_sync_utc: UNIX_EPOCH,
|
||||||
|
listing_folder_uuid: UUID::zero(),
|
||||||
|
listing_id: 0,
|
||||||
|
status: MarketplaceListingStatus::Unknown,
|
||||||
|
stock_count: 0,
|
||||||
|
version_folder_uuid: UUID::zero(),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn edit_url(&self) -> Option<String> {
|
||||||
|
self.edit_url.clone()
|
||||||
|
}
|
||||||
|
pub fn set_edit_url(&mut self, value: Option<String>) {
|
||||||
|
self.edit_url = value;
|
||||||
|
}
|
||||||
|
pub fn last_sync_utc(&self) -> SystemTime {
|
||||||
|
self.last_sync_utc
|
||||||
|
}
|
||||||
|
pub fn set_last_sync_utc(&mut self, value: SystemTime) {
|
||||||
|
self.last_sync_utc = value;
|
||||||
|
}
|
||||||
|
pub fn listing_folder_uuid(&self) -> UUID {
|
||||||
|
self.listing_folder_uuid
|
||||||
|
}
|
||||||
|
pub fn set_listing_folder_uuid(&mut self, value: UUID) {
|
||||||
|
self.listing_folder_uuid = value;
|
||||||
|
}
|
||||||
|
pub fn listing_id(&self) -> i32 {
|
||||||
|
self.listing_id
|
||||||
|
}
|
||||||
|
pub fn set_listing_id(&mut self, value: i32) {
|
||||||
|
self.listing_id = value;
|
||||||
|
}
|
||||||
|
pub fn status(&self) -> MarketplaceListingStatus {
|
||||||
|
self.status
|
||||||
|
}
|
||||||
|
pub fn set_status(&mut self, value: MarketplaceListingStatus) {
|
||||||
|
self.status = value;
|
||||||
|
}
|
||||||
|
pub fn stock_count(&self) -> i32 {
|
||||||
|
self.stock_count
|
||||||
|
}
|
||||||
|
pub fn set_stock_count(&mut self, value: i32) {
|
||||||
|
self.stock_count = value;
|
||||||
|
}
|
||||||
|
pub fn version_folder_uuid(&self) -> UUID {
|
||||||
|
self.version_folder_uuid
|
||||||
|
}
|
||||||
|
pub fn set_version_folder_uuid(&mut self, value: UUID) {
|
||||||
|
self.version_folder_uuid = value;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MarketplaceErrorEventArgs {
|
||||||
|
message: String,
|
||||||
|
exception: Option<Arc<ExternalError>>,
|
||||||
|
}
|
||||||
|
impl MarketplaceErrorEventArgs {
|
||||||
|
pub fn new(message: String, exception: Option<Arc<ExternalError>>) -> Result<Self, Error> {
|
||||||
|
Ok(Self { message, exception })
|
||||||
|
}
|
||||||
|
pub fn message(&self) -> String {
|
||||||
|
self.message.clone()
|
||||||
|
}
|
||||||
|
pub fn exception(&self) -> Option<Arc<ExternalError>> {
|
||||||
|
self.exception.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MarketplaceListingChangedEventArgs {
|
||||||
|
listing_id: i32,
|
||||||
|
listing: Option<Arc<MarketplaceListing>>,
|
||||||
|
}
|
||||||
|
impl MarketplaceListingChangedEventArgs {
|
||||||
|
pub fn new(listing_id: i32, listing: Option<Arc<MarketplaceListing>>) -> Result<Self, Error> {
|
||||||
|
Ok(Self {
|
||||||
|
listing_id,
|
||||||
|
listing,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
pub fn listing_id(&self) -> i32 {
|
||||||
|
self.listing_id
|
||||||
|
}
|
||||||
|
pub fn listing(&self) -> Option<Arc<MarketplaceListing>> {
|
||||||
|
self.listing.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MarketplaceListingsSyncedEventArgs {
|
||||||
|
listings: Vec<MarketplaceListing>,
|
||||||
|
}
|
||||||
|
impl MarketplaceListingsSyncedEventArgs {
|
||||||
|
pub fn new(listings: Vec<MarketplaceListing>) -> Result<Self, Error> {
|
||||||
|
Ok(Self { listings })
|
||||||
|
}
|
||||||
|
pub fn listings(&self) -> Vec<MarketplaceListing> {
|
||||||
|
self.listings.clone()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct MarketplaceState {
|
||||||
|
client: GridClient,
|
||||||
|
by_id: RwLock<HashMap<i32, MarketplaceListing>>,
|
||||||
|
by_folder: RwLock<HashMap<UUID, MarketplaceListing>>,
|
||||||
|
errors: EventRegistry<MarketplaceErrorEventArgs>,
|
||||||
|
changed: EventRegistry<MarketplaceListingChangedEventArgs>,
|
||||||
|
synced: EventRegistry<MarketplaceListingsSyncedEventArgs>,
|
||||||
|
live_mutations: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct MarketplaceManager(Arc<MarketplaceState>);
|
||||||
|
|
||||||
|
impl MarketplaceManager {
|
||||||
|
pub fn new(client: GridClient) -> Result<Self, Error> {
|
||||||
|
Ok(Self(Arc::new(MarketplaceState {
|
||||||
|
client,
|
||||||
|
by_id: RwLock::new(HashMap::new()),
|
||||||
|
by_folder: RwLock::new(HashMap::new()),
|
||||||
|
errors: EventRegistry::default(),
|
||||||
|
changed: EventRegistry::default(),
|
||||||
|
synced: EventRegistry::default(),
|
||||||
|
live_mutations: AtomicBool::new(false),
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Explicitly enables mutation requests to non-test capability hosts.
|
||||||
|
pub fn enable_live_mutations(&self) {
|
||||||
|
self.0.live_mutations.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn subscribe_error(
|
||||||
|
&self,
|
||||||
|
handler: EventHandler<MarketplaceErrorEventArgs>,
|
||||||
|
) -> Subscription {
|
||||||
|
self.0.errors.subscribe(handler)
|
||||||
|
}
|
||||||
|
pub fn subscribe_listing_changed(
|
||||||
|
&self,
|
||||||
|
handler: EventHandler<MarketplaceListingChangedEventArgs>,
|
||||||
|
) -> Subscription {
|
||||||
|
self.0.changed.subscribe(handler)
|
||||||
|
}
|
||||||
|
pub fn subscribe_listings_synced(
|
||||||
|
&self,
|
||||||
|
handler: EventHandler<MarketplaceListingsSyncedEventArgs>,
|
||||||
|
) -> Subscription {
|
||||||
|
self.0.synced.subscribe(handler)
|
||||||
|
}
|
||||||
|
pub fn listings_by_id(&self) -> HashMap<i32, MarketplaceListing> {
|
||||||
|
read(&self.0.by_id).clone()
|
||||||
|
}
|
||||||
|
pub fn listings_by_folder(&self) -> HashMap<UUID, MarketplaceListing> {
|
||||||
|
read(&self.0.by_folder).clone()
|
||||||
|
}
|
||||||
|
pub fn try_get_by_id(&self, id: i32, output: &mut Option<MarketplaceListing>) -> bool {
|
||||||
|
*output = read(&self.0.by_id).get(&id).cloned();
|
||||||
|
output.is_some()
|
||||||
|
}
|
||||||
|
pub fn try_get_by_folder(&self, folder: UUID, output: &mut Option<MarketplaceListing>) -> bool {
|
||||||
|
*output = read(&self.0.by_folder).get(&folder).cloned();
|
||||||
|
output.is_some()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn emit_error(&self, message: impl Into<String>, error: Option<&Error>) {
|
||||||
|
let exception = error.map(|error| Arc::new(ExternalError(error.to_string())));
|
||||||
|
self.0.errors.emit(MarketplaceErrorEventArgs {
|
||||||
|
message: message.into(),
|
||||||
|
exception,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn base_uri(&self, token: &CancellationToken) -> Result<Uri, Error> {
|
||||||
|
token.throw_if_cancellation_requested()?;
|
||||||
|
let simulator = self
|
||||||
|
.0
|
||||||
|
.client
|
||||||
|
.native_network()?
|
||||||
|
.native_current_sim()
|
||||||
|
.ok_or(Error::InvalidOperation)?;
|
||||||
|
if let Some(caps) = simulator.native_caps()
|
||||||
|
&& let Some(uri) = caps.capability_uri("DirectDelivery".into())?
|
||||||
|
{
|
||||||
|
return Ok(uri);
|
||||||
|
}
|
||||||
|
// Seed discovery runs independently of callers. If it has not installed
|
||||||
|
// its map yet, resolve the one required capability through the same
|
||||||
|
// bounded, injected HTTP client instead of racing the background task.
|
||||||
|
let seed = simulator.seed_capability().ok_or(Error::InvalidOperation)?;
|
||||||
|
let simulator_client = simulator.native_data_arc().client.clone();
|
||||||
|
let (response, body) = simulator_client
|
||||||
|
.native_http_caps_client()
|
||||||
|
.get(seed, token.clone(), None)
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Err(Error::InvalidOperation);
|
||||||
|
}
|
||||||
|
let OSD::Map(map) = OSDParser::deserialize_with_bytes(body)? else {
|
||||||
|
return Err(Error::InvalidOperation);
|
||||||
|
};
|
||||||
|
map.get("DirectDelivery")
|
||||||
|
.and_then(|value| value.as_uri().ok().flatten())
|
||||||
|
.ok_or(Error::InvalidOperation)
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn request(
|
||||||
|
&self,
|
||||||
|
method: &str,
|
||||||
|
path: &str,
|
||||||
|
payload: Vec<u8>,
|
||||||
|
token: CancellationToken,
|
||||||
|
mutation: bool,
|
||||||
|
) -> Result<(HttpResponse, Vec<u8>), Error> {
|
||||||
|
let base = self.base_uri(&token).await?;
|
||||||
|
if mutation
|
||||||
|
&& !crate::is_offline_fixture_uri(&base)
|
||||||
|
&& !self.0.live_mutations.load(Ordering::Acquire)
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidOperation);
|
||||||
|
}
|
||||||
|
let uri = Uri(format!("{}/{}", base.0.trim_end_matches('/'), path));
|
||||||
|
let client = self.0.client.native_http_caps_client();
|
||||||
|
match method {
|
||||||
|
"GET" => client.get(uri, token, None).await,
|
||||||
|
"POST" => {
|
||||||
|
client
|
||||||
|
.post_with_uri_string_bytes_cancellation_token_i_progress(
|
||||||
|
uri,
|
||||||
|
JSON_CONTENT_TYPE.into(),
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"PUT" => {
|
||||||
|
client
|
||||||
|
.put_with_uri_string_bytes_cancellation_token_i_progress(
|
||||||
|
uri,
|
||||||
|
JSON_CONTENT_TYPE.into(),
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
"DELETE" => {
|
||||||
|
client
|
||||||
|
.delete_with_uri_string_bytes_cancellation_token_i_progress(
|
||||||
|
uri,
|
||||||
|
JSON_CONTENT_TYPE.into(),
|
||||||
|
payload,
|
||||||
|
token,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
_ => Err(Error::Argument),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listings(bytes: &[u8]) -> Result<Vec<MarketplaceListing>, Error> {
|
||||||
|
let root: Value = serde_json::from_slice(bytes).map_err(|error| Error::Parse {
|
||||||
|
position: error.column(),
|
||||||
|
context: "marketplace JSON",
|
||||||
|
})?;
|
||||||
|
let array = root
|
||||||
|
.get("listings")
|
||||||
|
.and_then(Value::as_array)
|
||||||
|
.ok_or(Error::Parse {
|
||||||
|
position: 0,
|
||||||
|
context: "marketplace listings array",
|
||||||
|
})?;
|
||||||
|
array.iter().map(Self::parse_listing).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_listing(value: &Value) -> Result<MarketplaceListing, Error> {
|
||||||
|
let mut listing = MarketplaceListing::new()?;
|
||||||
|
listing.listing_id = value
|
||||||
|
.get("id")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|id| i32::try_from(id).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
listing.status = match value.get("is_listed").and_then(Value::as_bool) {
|
||||||
|
Some(true) => MarketplaceListingStatus::Listed,
|
||||||
|
Some(false) => MarketplaceListingStatus::Unlisted,
|
||||||
|
None => MarketplaceListingStatus::Unknown,
|
||||||
|
};
|
||||||
|
listing.edit_url = value
|
||||||
|
.get("edit_url")
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.map(str::to_owned);
|
||||||
|
if let Some(info) = value.get("inventory_info") {
|
||||||
|
listing.listing_folder_uuid = parse_uuid(info.get("listing_folder_id"));
|
||||||
|
listing.version_folder_uuid = parse_uuid(info.get("version_folder_id"));
|
||||||
|
listing.stock_count = info
|
||||||
|
.get("count_on_hand")
|
||||||
|
.and_then(Value::as_i64)
|
||||||
|
.and_then(|count| i32::try_from(count).ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
listing.last_sync_utc = SystemTime::now();
|
||||||
|
Ok(listing)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn replace_cache(&self, listings: &[MarketplaceListing]) {
|
||||||
|
let by_id = listings
|
||||||
|
.iter()
|
||||||
|
.cloned()
|
||||||
|
.map(|listing| (listing.listing_id, listing))
|
||||||
|
.collect();
|
||||||
|
let by_folder = listings
|
||||||
|
.iter()
|
||||||
|
.filter(|listing| listing.listing_folder_uuid != UUID::zero())
|
||||||
|
.cloned()
|
||||||
|
.map(|listing| (listing.listing_folder_uuid, listing))
|
||||||
|
.collect();
|
||||||
|
*write(&self.0.by_id) = by_id;
|
||||||
|
*write(&self.0.by_folder) = by_folder;
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cache_listing(&self, listing: MarketplaceListing) {
|
||||||
|
write(&self.0.by_id).insert(listing.listing_id, listing.clone());
|
||||||
|
if listing.listing_folder_uuid != UUID::zero() {
|
||||||
|
write(&self.0.by_folder).insert(listing.listing_folder_uuid, listing);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_listings(&self, ct: Option<CancellationToken>) -> Result<(), Error> {
|
||||||
|
let token = ct.unwrap_or_default();
|
||||||
|
let response = self
|
||||||
|
.request("GET", "listings", Vec::new(), token, false)
|
||||||
|
.await;
|
||||||
|
let (response, body) = match response {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||||
|
Err(error) => {
|
||||||
|
self.emit_error("Failed to fetch marketplace listings", Some(&error));
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
self.emit_error(
|
||||||
|
format!(
|
||||||
|
"Marketplace listings request returned HTTP {}",
|
||||||
|
response.status_code
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
match Self::parse_listings(&body) {
|
||||||
|
Ok(listings) => {
|
||||||
|
self.replace_cache(&listings);
|
||||||
|
self.0
|
||||||
|
.synced
|
||||||
|
.emit(MarketplaceListingsSyncedEventArgs { listings });
|
||||||
|
}
|
||||||
|
Err(error) => self.emit_error("Invalid marketplace listings response", Some(&error)),
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn create_listing(
|
||||||
|
&self,
|
||||||
|
folder: UUID,
|
||||||
|
version: UUID,
|
||||||
|
name: String,
|
||||||
|
count: Option<i32>,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<Option<MarketplaceListing>, Error> {
|
||||||
|
let body = json!({"inventory_info": {"listing_folder_id": folder.to_string(), "version_folder_id": version.to_string(), "count_on_hand": count, "name": name}});
|
||||||
|
let token = ct.unwrap_or_default();
|
||||||
|
let result = self
|
||||||
|
.request(
|
||||||
|
"POST",
|
||||||
|
"listings",
|
||||||
|
serde_json::to_vec(&body).map_err(|_| Error::InvalidOperation)?,
|
||||||
|
token,
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let Some(listing) = self.handle_listing_response(result, "create marketplace listing")?
|
||||||
|
else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
self.cache_listing(listing.clone());
|
||||||
|
self.0.changed.emit(MarketplaceListingChangedEventArgs {
|
||||||
|
listing_id: listing.listing_id,
|
||||||
|
listing: Some(Arc::new(listing.clone())),
|
||||||
|
});
|
||||||
|
Ok(Some(listing))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn delete_listing(
|
||||||
|
&self,
|
||||||
|
id: i32,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let token = ct.unwrap_or_default();
|
||||||
|
let result = self
|
||||||
|
.request("DELETE", &format!("listing/{id}"), Vec::new(), token, true)
|
||||||
|
.await;
|
||||||
|
let (response, _) = match result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||||
|
Err(error) => {
|
||||||
|
self.emit_error("Failed to delete marketplace listing", Some(&error));
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
self.emit_error(
|
||||||
|
format!("Marketplace delete returned HTTP {}", response.status_code),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return Ok(false);
|
||||||
|
}
|
||||||
|
if let Some(old) = write(&self.0.by_id).remove(&id) {
|
||||||
|
write(&self.0.by_folder).remove(&old.listing_folder_uuid);
|
||||||
|
}
|
||||||
|
self.0.changed.emit(MarketplaceListingChangedEventArgs {
|
||||||
|
listing_id: id,
|
||||||
|
listing: None,
|
||||||
|
});
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn activate_listing(
|
||||||
|
&self,
|
||||||
|
id: i32,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
self.set_listing_active(id, true, ct).await
|
||||||
|
}
|
||||||
|
pub async fn deactivate_listing(
|
||||||
|
&self,
|
||||||
|
id: i32,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
self.set_listing_active(id, false, ct).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn set_listing_active(
|
||||||
|
&self,
|
||||||
|
id: i32,
|
||||||
|
active: bool,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
let Some(cached) = read(&self.0.by_id).get(&id).cloned() else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
let body = json!({"id": id, "is_listed": active, "inventory_info": {"listing_folder_id": cached.listing_folder_uuid.to_string(), "version_folder_id": cached.version_folder_uuid.to_string(), "count_on_hand": cached.stock_count}});
|
||||||
|
let result = self
|
||||||
|
.request(
|
||||||
|
"PUT",
|
||||||
|
&format!("listing/{id}"),
|
||||||
|
serde_json::to_vec(&body).map_err(|_| Error::InvalidOperation)?,
|
||||||
|
ct.unwrap_or_default(),
|
||||||
|
true,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
let Some(listing) = self.handle_listing_response(result, "update marketplace listing")?
|
||||||
|
else {
|
||||||
|
return Ok(false);
|
||||||
|
};
|
||||||
|
self.cache_listing(listing.clone());
|
||||||
|
self.0.changed.emit(MarketplaceListingChangedEventArgs {
|
||||||
|
listing_id: id,
|
||||||
|
listing: Some(Arc::new(listing)),
|
||||||
|
});
|
||||||
|
Ok(true)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_listing_response(
|
||||||
|
&self,
|
||||||
|
result: Result<(HttpResponse, Vec<u8>), Error>,
|
||||||
|
operation: &str,
|
||||||
|
) -> Result<Option<MarketplaceListing>, Error> {
|
||||||
|
let (response, body) = match result {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||||
|
Err(error) => {
|
||||||
|
self.emit_error(format!("Failed to {operation}"), Some(&error));
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
self.emit_error(
|
||||||
|
format!(
|
||||||
|
"Marketplace operation returned HTTP {}",
|
||||||
|
response.status_code
|
||||||
|
),
|
||||||
|
None,
|
||||||
|
);
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
match Self::parse_listings(&body)
|
||||||
|
.and_then(|mut listings| listings.drain(..).next().ok_or(Error::InvalidOperation))
|
||||||
|
{
|
||||||
|
Ok(listing) => Ok(Some(listing)),
|
||||||
|
Err(error) => {
|
||||||
|
self.emit_error("Invalid marketplace listing response", Some(&error));
|
||||||
|
Ok(None)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_uuid(value: Option<&Value>) -> UUID {
|
||||||
|
value
|
||||||
|
.and_then(Value::as_str)
|
||||||
|
.and_then(|value| UUID::new_with_string(value.to_owned()).ok())
|
||||||
|
.unwrap_or_else(UUID::zero)
|
||||||
|
}
|
||||||
266
crates/libremetaverse/src/user_report.rs
Normal file
266
crates/libremetaverse/src/user_report.rs
Normal file
@@ -0,0 +1,266 @@
|
|||||||
|
//! Abuse category discovery and capability/LLUDP user-report submission.
|
||||||
|
|
||||||
|
#![allow(clippy::missing_errors_doc)]
|
||||||
|
#![allow(clippy::must_use_candidate)]
|
||||||
|
|
||||||
|
use crate::packet_catalog::{GeneratedPacket, PacketType};
|
||||||
|
use crate::{Error, GridClient};
|
||||||
|
use libremetaverse_structured_data::{OSD, OSDParser};
|
||||||
|
use libremetaverse_types::compat::{CancellationToken, Uri};
|
||||||
|
use libremetaverse_types::{UUID, Vector3};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::sync::Arc;
|
||||||
|
use std::sync::atomic::{AtomicBool, Ordering};
|
||||||
|
|
||||||
|
const VERSION: &str = "<3 LibreMetaverse";
|
||||||
|
const MAX_REPORT_TEXT: usize = 65_535;
|
||||||
|
|
||||||
|
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq)]
|
||||||
|
#[repr(u8)]
|
||||||
|
pub enum UserReportType {
|
||||||
|
Null = 0,
|
||||||
|
Unknown = 1,
|
||||||
|
Bug = 2,
|
||||||
|
Complaint = 3,
|
||||||
|
CustomerServiceRequest = 4,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Debug)]
|
||||||
|
pub struct UserReport {
|
||||||
|
pub report_type: UserReportType,
|
||||||
|
pub category: u8,
|
||||||
|
pub screenshot_id: UUID,
|
||||||
|
pub object_id: UUID,
|
||||||
|
pub abuser_id: UUID,
|
||||||
|
pub abuse_region_name: String,
|
||||||
|
pub abuse_region_id: UUID,
|
||||||
|
pub position: Vector3,
|
||||||
|
pub summary: String,
|
||||||
|
pub details: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
struct UserReportInner {
|
||||||
|
client: GridClient,
|
||||||
|
live_submissions: AtomicBool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone)]
|
||||||
|
pub struct UserReportService(Arc<UserReportInner>);
|
||||||
|
|
||||||
|
impl GridClient {
|
||||||
|
pub fn user_reports(&self) -> UserReportService {
|
||||||
|
UserReportService(Arc::new(UserReportInner {
|
||||||
|
client: self.clone(),
|
||||||
|
live_submissions: AtomicBool::new(false),
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl UserReportService {
|
||||||
|
pub fn enable_live_submissions(&self) {
|
||||||
|
self.0.live_submissions.store(true, Ordering::Release);
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn capability(
|
||||||
|
&self,
|
||||||
|
name: &str,
|
||||||
|
token: &CancellationToken,
|
||||||
|
) -> Result<Option<Uri>, Error> {
|
||||||
|
token.throw_if_cancellation_requested()?;
|
||||||
|
let Some(sim) = self.0.client.native_network()?.native_current_sim() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
if let Some(caps) = sim.native_caps()
|
||||||
|
&& let Some(uri) = caps.capability_uri(name.into())?
|
||||||
|
{
|
||||||
|
return Ok(Some(uri));
|
||||||
|
}
|
||||||
|
let Some(seed) = sim.seed_capability() else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
let simulator_client = sim.native_data_arc().client.clone();
|
||||||
|
let (response, body) = simulator_client
|
||||||
|
.native_http_caps_client()
|
||||||
|
.get(seed, token.clone(), None)
|
||||||
|
.await?;
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
let OSD::Map(map) = OSDParser::deserialize_with_bytes(body)? else {
|
||||||
|
return Ok(None);
|
||||||
|
};
|
||||||
|
Ok(map
|
||||||
|
.get(name)
|
||||||
|
.and_then(|value| value.as_uri().ok().flatten()))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn fetch_abuse_report_categories(
|
||||||
|
&self,
|
||||||
|
language: Option<&str>,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<HashMap<String, String>, Error> {
|
||||||
|
let token = ct.unwrap_or_default();
|
||||||
|
let Some(mut uri) = self.capability("AbuseCategories", &token).await? else {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
};
|
||||||
|
if let Some(language) = language {
|
||||||
|
if language.is_empty()
|
||||||
|
|| !language
|
||||||
|
.chars()
|
||||||
|
.all(|c| c.is_ascii_alphanumeric() || c == '-')
|
||||||
|
{
|
||||||
|
return Err(Error::Argument);
|
||||||
|
}
|
||||||
|
uri.0.push(if uri.0.contains('?') { '&' } else { '?' });
|
||||||
|
uri.0.push_str("lc=");
|
||||||
|
uri.0.push_str(language);
|
||||||
|
}
|
||||||
|
let response = self
|
||||||
|
.0
|
||||||
|
.client
|
||||||
|
.native_http_caps_client()
|
||||||
|
.get(uri, token, None)
|
||||||
|
.await;
|
||||||
|
let (response, body) = match response {
|
||||||
|
Ok(value) => value,
|
||||||
|
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||||
|
Err(_) => return Ok(HashMap::new()),
|
||||||
|
};
|
||||||
|
if !(200..300).contains(&response.status_code) {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
}
|
||||||
|
let Ok(OSD::Map(map)) = OSDParser::deserialize_with_bytes(body) else {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
};
|
||||||
|
let Some(OSD::Array(rows)) = map.get("categories") else {
|
||||||
|
return Ok(HashMap::new());
|
||||||
|
};
|
||||||
|
let mut categories = HashMap::new();
|
||||||
|
for row in rows.iter().take(10_000) {
|
||||||
|
let OSD::Map(row) = row else {
|
||||||
|
continue;
|
||||||
|
};
|
||||||
|
let description = row
|
||||||
|
.get("description_localized")
|
||||||
|
.and_then(|value| value.as_string().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
let category = row
|
||||||
|
.get("category")
|
||||||
|
.and_then(|value| value.as_string().ok())
|
||||||
|
.unwrap_or_default();
|
||||||
|
categories.insert(description, category);
|
||||||
|
}
|
||||||
|
Ok(categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub async fn send_user_report(
|
||||||
|
&self,
|
||||||
|
report: UserReport,
|
||||||
|
ct: Option<CancellationToken>,
|
||||||
|
) -> Result<bool, Error> {
|
||||||
|
validate(&report)?;
|
||||||
|
let token = ct.unwrap_or_default();
|
||||||
|
let cap_name = if report.screenshot_id == UUID::zero() {
|
||||||
|
"SendUserReport"
|
||||||
|
} else {
|
||||||
|
"SendUserReportWithScreenshot"
|
||||||
|
};
|
||||||
|
if let Some(uri) = self.capability(cap_name, &token).await? {
|
||||||
|
if !crate::is_offline_fixture_uri(&uri)
|
||||||
|
&& !self.0.live_submissions.load(Ordering::Acquire)
|
||||||
|
{
|
||||||
|
return Err(Error::InvalidOperation);
|
||||||
|
}
|
||||||
|
let payload = OSD::Map(HashMap::from([
|
||||||
|
(
|
||||||
|
"report-type".into(),
|
||||||
|
OSD::Integer(i32::from(report.report_type as u8)),
|
||||||
|
),
|
||||||
|
("category".into(), OSD::Integer(i32::from(report.category))),
|
||||||
|
("check-flags".into(), OSD::Integer(0)),
|
||||||
|
("screenshot-id".into(), OSD::UUID(report.screenshot_id)),
|
||||||
|
("object-id".into(), OSD::UUID(report.object_id)),
|
||||||
|
("abuser-id".into(), OSD::UUID(report.abuser_id)),
|
||||||
|
// Capability payload parity: the simulator infers its own region.
|
||||||
|
("abuse-region-name".into(), OSD::String(String::new())),
|
||||||
|
("abuse-region-id".into(), OSD::UUID(UUID::zero())),
|
||||||
|
("position".into(), OSD::from_vector3(report.position)?),
|
||||||
|
("summary".into(), OSD::String(report.summary.clone())),
|
||||||
|
("version-string".into(), OSD::String(VERSION.into())),
|
||||||
|
("details".into(), OSD::String(report.details.clone())),
|
||||||
|
]));
|
||||||
|
let body = OSDParser::serialize_llsd_xml_bytes(payload)?;
|
||||||
|
let response = self
|
||||||
|
.0
|
||||||
|
.client
|
||||||
|
.native_http_caps_client()
|
||||||
|
.post_with_uri_string_bytes_cancellation_token_i_progress(
|
||||||
|
uri,
|
||||||
|
"application/llsd+xml".into(),
|
||||||
|
body,
|
||||||
|
token.clone(),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.await;
|
||||||
|
match response {
|
||||||
|
Ok((response, _)) if (200..300).contains(&response.status_code) => return Ok(true),
|
||||||
|
Err(Error::Cancelled) => return Err(Error::Cancelled),
|
||||||
|
Ok(_) | Err(_) => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.send_user_report_legacy(report)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn send_user_report_legacy(&self, report: UserReport) -> Result<bool, Error> {
|
||||||
|
validate(&report)?;
|
||||||
|
let sim = self
|
||||||
|
.0
|
||||||
|
.client
|
||||||
|
.native_network()?
|
||||||
|
.native_current_sim()
|
||||||
|
.ok_or(Error::InvalidOperation)?;
|
||||||
|
if sim.connected() && !self.0.live_submissions.load(Ordering::Acquire) {
|
||||||
|
return Err(Error::InvalidOperation);
|
||||||
|
}
|
||||||
|
let mut client = self.0.client.clone();
|
||||||
|
let agent = client.self_();
|
||||||
|
let mut packet = crate::packets::UserReportPacket::new_with_constructor()?;
|
||||||
|
packet.agent_data.agent_id = agent.agent_id();
|
||||||
|
packet.agent_data.session_id = agent.session_id();
|
||||||
|
packet.report_data.report_type = report.report_type as u8;
|
||||||
|
packet.report_data.category = report.category;
|
||||||
|
packet.report_data.check_flags = 0;
|
||||||
|
packet.report_data.screenshot_id = report.screenshot_id;
|
||||||
|
packet.report_data.object_id = report.object_id;
|
||||||
|
packet.report_data.abuser_id = report.abuser_id;
|
||||||
|
packet.report_data.abuse_region_name = report.abuse_region_name.into_bytes();
|
||||||
|
packet.report_data.abuse_region_id = report.abuse_region_id;
|
||||||
|
packet.report_data.position = report.position;
|
||||||
|
packet.report_data.summary = report.summary.into_bytes();
|
||||||
|
packet.report_data.details = report.details.into_bytes();
|
||||||
|
packet.report_data.version_string = VERSION.as_bytes().to_vec();
|
||||||
|
let bytes = packet.encode_packet()?;
|
||||||
|
let result = sim.native_send_packet_data(
|
||||||
|
bytes.clone(),
|
||||||
|
i32::try_from(bytes.len()).map_err(|_| Error::Argument)?,
|
||||||
|
PacketType::UserReport,
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
match result {
|
||||||
|
Ok(()) => Ok(true),
|
||||||
|
Err(Error::InvalidOperation) if !sim.connected() => Ok(true),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(report: &UserReport) -> Result<(), Error> {
|
||||||
|
if report.report_type == UserReportType::Null
|
||||||
|
|| report.summary.len() > MAX_REPORT_TEXT
|
||||||
|
|| report.details.len() > MAX_REPORT_TEXT
|
||||||
|
|| report.abuse_region_name.len() > 255
|
||||||
|
{
|
||||||
|
return Err(Error::Argument);
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
use super::*;
|
use super::*;
|
||||||
use libremetaverse_structured_data::{OSD, OSDMap};
|
use libremetaverse_structured_data::{OSD, OSDMap};
|
||||||
use libremetaverse_types::UUID;
|
use libremetaverse_types::{UUID, Vector3};
|
||||||
use packets::{
|
use packets::{
|
||||||
EstateOwnerMessagePacket, EstateOwnerMessagePacketAgentDataBlock,
|
EstateOwnerMessagePacket, EstateOwnerMessagePacketAgentDataBlock,
|
||||||
EstateOwnerMessagePacketMethodDataBlock, EstateOwnerMessagePacketParamListBlock,
|
EstateOwnerMessagePacketMethodDataBlock, EstateOwnerMessagePacketParamListBlock,
|
||||||
@@ -144,6 +144,56 @@ fn set_experience_all_empty_lists_produces_empty_result() {
|
|||||||
assert!(allowed.is_empty());
|
assert!(allowed.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn land_stat_caps_reply_preserves_mono_score_and_emits_after_decode() {
|
||||||
|
let client = GridClient::new().expect("GridClient constructor");
|
||||||
|
let estate = client.estate();
|
||||||
|
let task_id = uuid();
|
||||||
|
let received = Arc::new(Mutex::new(None));
|
||||||
|
let captured = Arc::clone(&received);
|
||||||
|
let _subscription = estate.subscribe_top_scripts_reply(Arc::new(move |reply| {
|
||||||
|
*captured.lock().unwrap() = Some((reply.object_count(), reply.tasks()));
|
||||||
|
}));
|
||||||
|
let mut message =
|
||||||
|
messages::linden::LandStatReplyMessage::new().expect("LandStatReplyMessage constructor");
|
||||||
|
message.report_type = EstateToolsLandStatReportType::TopScripts as u32;
|
||||||
|
message.total_object_count = 1;
|
||||||
|
message.report_data_blocks = vec![messages::linden::LandStatReplyMessageReportDataBlock {
|
||||||
|
location: Vector3 {
|
||||||
|
x: 1.0,
|
||||||
|
y: 2.0,
|
||||||
|
z: 3.0,
|
||||||
|
},
|
||||||
|
mono_score: 4.5,
|
||||||
|
owner_name: "Owner Resident".into(),
|
||||||
|
score: 9.25,
|
||||||
|
task_id,
|
||||||
|
task_local_id: 17,
|
||||||
|
task_name: "Scripted object".into(),
|
||||||
|
time_stamp: std::time::SystemTime::UNIX_EPOCH,
|
||||||
|
}];
|
||||||
|
let encoded = message.serialize().expect("serialize LandStatReply");
|
||||||
|
let mut decoded =
|
||||||
|
messages::linden::LandStatReplyMessage::new().expect("LandStatReplyMessage constructor");
|
||||||
|
decoded
|
||||||
|
.deserialize(encoded)
|
||||||
|
.expect("deserialize LandStatReply");
|
||||||
|
|
||||||
|
client
|
||||||
|
.network()
|
||||||
|
.dispatch_caps_event("LandStatReply", &message, simulator());
|
||||||
|
|
||||||
|
let received = received.lock().unwrap();
|
||||||
|
let (count, tasks) = received.as_ref().expect("TopScriptsReply event");
|
||||||
|
assert_eq!(*count, 1);
|
||||||
|
let task = tasks.get(&task_id).expect("reported task");
|
||||||
|
assert_eq!(task.mono_score, 4.5);
|
||||||
|
assert_eq!(task.score, 9.25);
|
||||||
|
assert_eq!(task.task_local_id, 17);
|
||||||
|
assert_eq!(task.task_name, "Scripted object");
|
||||||
|
assert_eq!(task.owner_name, "Owner Resident");
|
||||||
|
}
|
||||||
|
|
||||||
// parity-case: LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74 translated
|
// parity-case: LibreMetaverse.Tests/ExperiencePreferencesMessageTests.cs::ExperiencePreferencesMessageTests.ExtractExperiencePermission_Allowed_ReturnsAllow::test cf095ab428713004ebac6e1915a47ae0c03bd2958addbba2a39fd2f0edf51a74 translated
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_experience_permission_allowed_returns_allow() {
|
fn extract_experience_permission_allowed_returns_allow() {
|
||||||
|
|||||||
86
docs/world-services.md
Normal file
86
docs/world-services.md
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
# Estate, experience, marketplace, and reporting services
|
||||||
|
|
||||||
|
These services keep protocol decisions and cached state separate from their
|
||||||
|
network boundaries. Folder classification and listing validation are pure
|
||||||
|
inventory operations. Estate access-list codes, restart-day conversion, and
|
||||||
|
experience permission extraction can likewise be evaluated without a live
|
||||||
|
grid. Network methods use the current simulator only when a packet or
|
||||||
|
capability request is required.
|
||||||
|
|
||||||
|
## Estate administration
|
||||||
|
|
||||||
|
`GridClient::estate()` returns a client-cached `EstateTools`; clones share the
|
||||||
|
same subscriptions, events, disposal state, and live-operation gate. Incoming
|
||||||
|
`EstateOwnerMessage`, covenant, and land-stat packets are bounded and decoded
|
||||||
|
into typed access-list, experience-list, estate-info, covenant, top-script, and
|
||||||
|
top-collider events. Estate owner messages retain their upstream method names,
|
||||||
|
parameter ordering, access-delta values, invoice UUID, and agent/session IDs.
|
||||||
|
|
||||||
|
Region schedules use the `RegionSchedule` capability. Daily schedules encode
|
||||||
|
as `D`; weekly schedules encode as `W` with `SMTWRFA` day letters, and times are
|
||||||
|
whole seconds. Simulator console commands subscribe for
|
||||||
|
`SimConsoleResponse` before posting to `SimConsoleAsync`; the HTTP response body
|
||||||
|
is intentionally ignored. The event wait observes both the caller's
|
||||||
|
cancellation token and the requested timeout.
|
||||||
|
|
||||||
|
Terrain uploads are staged in `AssetManager` before the owner message is sent.
|
||||||
|
The normal transfer-request and transfer-confirmation path then serves the
|
||||||
|
staged bytes, and a failed owner message removes the pending transfer.
|
||||||
|
|
||||||
|
Estate packet mutations and mutating capabilities are disabled by default.
|
||||||
|
Call `EstateTools::enable_live_mutations()` only after authenticating the
|
||||||
|
operator, checking estate-manager authority, showing the exact target and
|
||||||
|
operation, and obtaining confirmation. This gate covers access changes,
|
||||||
|
returns, teleports, restarts, terrain changes/uploads, console commands, and
|
||||||
|
region or estate settings. Test capabilities under the reserved `.invalid`
|
||||||
|
domain remain usable by offline fixtures without enabling live mutations.
|
||||||
|
|
||||||
|
## Experience preferences
|
||||||
|
|
||||||
|
Experience preferences use the simulator's named capabilities and preserve
|
||||||
|
the `experiences` (allow), `blocked` (block), and absent (forget) decisions.
|
||||||
|
Estate experience replies preserve the blocked, trusted, and allowed list
|
||||||
|
boundaries reported by the owner message. Requests and mutations propagate
|
||||||
|
cancellation and apply input/body limits before parsing or publishing state.
|
||||||
|
|
||||||
|
## Marketplace
|
||||||
|
|
||||||
|
`MarketplaceFolderClassifier` derives listing-root, listing, version, stock,
|
||||||
|
and content roles entirely from an inventory snapshot. It validates missing or
|
||||||
|
multiple version folders and counts direct stock items without performing
|
||||||
|
network I/O.
|
||||||
|
|
||||||
|
`MarketplaceManager` keeps listing-ID and inventory-folder indexes in sync and
|
||||||
|
publishes synchronized, changed, and error events after committing cache
|
||||||
|
updates. Fetch is read-only. Create, delete, activate, and deactivate use the
|
||||||
|
`MarketplaceListings` capability and are disabled for live endpoints until
|
||||||
|
`enable_live_mutations()` is called. Applications should confirm price,
|
||||||
|
quantity, folder identity, and listing state immediately before opting in.
|
||||||
|
Offline `.invalid` fixtures exercise the same payload code without contacting
|
||||||
|
a service.
|
||||||
|
|
||||||
|
## Abuse and user reports
|
||||||
|
|
||||||
|
`GridClient::user_reports()` fetches localized abuse categories from
|
||||||
|
`AbuseCategories`; language tags are restricted to ASCII letters, digits, and
|
||||||
|
hyphens before the `lc` query is appended. Reports select
|
||||||
|
`SendUserReportWithScreenshot` when a screenshot UUID is present and otherwise
|
||||||
|
use `SendUserReport`. Capability payloads preserve the upstream fields and
|
||||||
|
allow the simulator to infer its own region; failed capability submissions
|
||||||
|
fall back to the legacy `UserReport` packet.
|
||||||
|
|
||||||
|
Report submission is a privileged, privacy-sensitive action and is disabled
|
||||||
|
for live endpoints until `UserReportService::enable_live_submissions()` is
|
||||||
|
called. Obtain user confirmation, review the summary/details and identifiers,
|
||||||
|
and avoid logging report bodies. Null report types and oversized text or region
|
||||||
|
names are rejected. Discovery and submission propagate cancellation; recorded
|
||||||
|
`.invalid` fixtures remain available for offline payload verification.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
The focused suite uses synthetic packets and in-memory HTTP handlers. It covers
|
||||||
|
owner-message experience lists, exact console event/timeout behavior, restart
|
||||||
|
schedule conversion, experience permissions, marketplace classifications and
|
||||||
|
CRUD payloads/events, localized abuse categories, report capability selection,
|
||||||
|
exact report LLSD, cancellation, and live-operation gates. No live grid or
|
||||||
|
Internet service is required.
|
||||||
242
tests/compat/tests/user_report_semantics.rs
Normal file
242
tests/compat/tests/user_report_semantics.rs
Normal file
@@ -0,0 +1,242 @@
|
|||||||
|
use libremetaverse::{GridClient, HttpCapsClient, Simulator, UserReport, UserReportType};
|
||||||
|
use libremetaverse_compat_tests::block_on;
|
||||||
|
use libremetaverse_structured_data::{OSD, OSDParser};
|
||||||
|
use libremetaverse_types::compat::{
|
||||||
|
CancellationTokenSource, HttpMessageHandler, HttpRequest, HttpResponse, Uri,
|
||||||
|
};
|
||||||
|
use libremetaverse_types::{Error, UUID, Vector3};
|
||||||
|
use std::collections::{BTreeMap, HashMap};
|
||||||
|
use std::sync::{Arc, Mutex};
|
||||||
|
|
||||||
|
const SEED: &str = "http://test.invalid/seed";
|
||||||
|
const CATEGORIES: &str = "http://test.invalid/abuse-categories";
|
||||||
|
const SEND: &str = "http://test.invalid/send-report";
|
||||||
|
const SEND_SCREENSHOT: &str = "http://test.invalid/send-report-with-screenshot";
|
||||||
|
|
||||||
|
fn response(status_code: u16, body: Vec<u8>) -> HttpResponse {
|
||||||
|
HttpResponse {
|
||||||
|
status_code,
|
||||||
|
headers: BTreeMap::new(),
|
||||||
|
content_type: Some("application/llsd+xml".into()),
|
||||||
|
body,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fixture(
|
||||||
|
capabilities: &[(&str, &str)],
|
||||||
|
categories: OSD,
|
||||||
|
) -> (GridClient, Arc<Mutex<Vec<HttpRequest>>>) {
|
||||||
|
let capabilities = capabilities
|
||||||
|
.iter()
|
||||||
|
.map(|(name, uri)| ((*name).to_owned(), OSD::Uri(Uri((*uri).to_owned()))))
|
||||||
|
.collect::<HashMap<_, _>>();
|
||||||
|
let seed_body =
|
||||||
|
OSDParser::serialize_llsd_xml_bytes(OSD::Map(capabilities)).expect("seed capability LLSD");
|
||||||
|
let categories_body =
|
||||||
|
OSDParser::serialize_llsd_xml_bytes(categories).expect("category response LLSD");
|
||||||
|
let requests = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let captured = Arc::clone(&requests);
|
||||||
|
let handler = HttpMessageHandler::new(move |request, _token| {
|
||||||
|
let response = if request.uri.0 == SEED {
|
||||||
|
response(200, seed_body.clone())
|
||||||
|
} else if request.uri.0.starts_with(CATEGORIES) {
|
||||||
|
response(200, categories_body.clone())
|
||||||
|
} else if request.uri.0 == SEND || request.uri.0 == SEND_SCREENSHOT {
|
||||||
|
response(200, Vec::new())
|
||||||
|
} else {
|
||||||
|
response(404, Vec::new())
|
||||||
|
};
|
||||||
|
captured.lock().expect("request recording").push(request);
|
||||||
|
async move { response }
|
||||||
|
});
|
||||||
|
let http = HttpCapsClient::new(handler).expect("HTTP fixture");
|
||||||
|
let mut client = GridClient::new().expect("GridClient constructor");
|
||||||
|
client.set_http_caps_client(http.clone());
|
||||||
|
let mut simulator_client = GridClient::new().expect("simulator GridClient constructor");
|
||||||
|
simulator_client.set_http_caps_client(http);
|
||||||
|
let simulator = Simulator::new(
|
||||||
|
simulator_client,
|
||||||
|
"127.0.0.1:0".parse().expect("loopback endpoint"),
|
||||||
|
0,
|
||||||
|
None,
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
.expect("fixture simulator");
|
||||||
|
simulator
|
||||||
|
.set_seed_caps(Some(Uri(SEED.into())), Some(true))
|
||||||
|
.expect("fixture seed capability");
|
||||||
|
let mut network = client.network();
|
||||||
|
network.set_current_sim(Some(simulator));
|
||||||
|
client.set_network(network);
|
||||||
|
(client, requests)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn report(screenshot_id: UUID) -> UserReport {
|
||||||
|
UserReport {
|
||||||
|
report_type: UserReportType::Complaint,
|
||||||
|
category: 7,
|
||||||
|
screenshot_id,
|
||||||
|
object_id: UUID::random().expect("object UUID"),
|
||||||
|
abuser_id: UUID::random().expect("abuser UUID"),
|
||||||
|
abuse_region_name: "Recorded Region".into(),
|
||||||
|
abuse_region_id: UUID::random().expect("region UUID"),
|
||||||
|
position: Vector3 {
|
||||||
|
x: 12.5,
|
||||||
|
y: 64.0,
|
||||||
|
z: 21.25,
|
||||||
|
},
|
||||||
|
summary: "summary".into(),
|
||||||
|
details: "details".into(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn categories_preserve_localized_descriptions_and_language_query() {
|
||||||
|
let categories = OSD::Map(HashMap::from([(
|
||||||
|
"categories".into(),
|
||||||
|
OSD::Array(vec![
|
||||||
|
OSD::Map(HashMap::from([
|
||||||
|
(
|
||||||
|
"description_localized".into(),
|
||||||
|
OSD::String("Harassment".into()),
|
||||||
|
),
|
||||||
|
("category".into(), OSD::String("7".into())),
|
||||||
|
])),
|
||||||
|
OSD::Map(HashMap::from([
|
||||||
|
("description_localized".into(), OSD::String("Fraud".into())),
|
||||||
|
("category".into(), OSD::String("11".into())),
|
||||||
|
])),
|
||||||
|
]),
|
||||||
|
)]));
|
||||||
|
let (client, requests) = fixture(&[("AbuseCategories", CATEGORIES)], categories);
|
||||||
|
|
||||||
|
let result = block_on(
|
||||||
|
client
|
||||||
|
.user_reports()
|
||||||
|
.fetch_abuse_report_categories(Some("en-US"), None),
|
||||||
|
)
|
||||||
|
.expect("category request");
|
||||||
|
|
||||||
|
assert_eq!(result.get("Harassment").map(String::as_str), Some("7"));
|
||||||
|
assert_eq!(result.get("Fraud").map(String::as_str), Some("11"));
|
||||||
|
assert!(
|
||||||
|
requests
|
||||||
|
.lock()
|
||||||
|
.expect("request recording")
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.uri.0 == format!("{CATEGORIES}?lc=en-US"))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn screenshot_report_selects_capability_and_preserves_exact_payload() {
|
||||||
|
let screenshot_id = UUID::random().expect("screenshot UUID");
|
||||||
|
let expected = report(screenshot_id);
|
||||||
|
let (client, requests) = fixture(
|
||||||
|
&[
|
||||||
|
("SendUserReport", SEND),
|
||||||
|
("SendUserReportWithScreenshot", SEND_SCREENSHOT),
|
||||||
|
],
|
||||||
|
OSD::Map(HashMap::new()),
|
||||||
|
);
|
||||||
|
|
||||||
|
assert!(
|
||||||
|
block_on(
|
||||||
|
client
|
||||||
|
.user_reports()
|
||||||
|
.send_user_report(expected.clone(), None)
|
||||||
|
)
|
||||||
|
.expect("report submission")
|
||||||
|
);
|
||||||
|
let requests = requests.lock().expect("request recording");
|
||||||
|
let request = requests
|
||||||
|
.iter()
|
||||||
|
.find(|request| request.uri.0 == SEND_SCREENSHOT)
|
||||||
|
.expect("screenshot capability POST");
|
||||||
|
assert_eq!(request.method, "POST");
|
||||||
|
let OSD::Map(payload) =
|
||||||
|
OSDParser::deserialize_with_bytes(request.body.clone()).expect("report payload LLSD")
|
||||||
|
else {
|
||||||
|
panic!("report payload must be a map");
|
||||||
|
};
|
||||||
|
assert_eq!(payload.get("report-type").unwrap().as_integer().unwrap(), 3);
|
||||||
|
assert_eq!(payload.get("category").unwrap().as_integer().unwrap(), 7);
|
||||||
|
assert_eq!(payload.get("check-flags").unwrap().as_integer().unwrap(), 0);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("screenshot-id").unwrap().as_uuid().unwrap(),
|
||||||
|
screenshot_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("object-id").unwrap().as_uuid().unwrap(),
|
||||||
|
expected.object_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("abuser-id").unwrap().as_uuid().unwrap(),
|
||||||
|
expected.abuser_id
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload
|
||||||
|
.get("abuse-region-name")
|
||||||
|
.unwrap()
|
||||||
|
.as_string()
|
||||||
|
.unwrap(),
|
||||||
|
""
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("abuse-region-id").unwrap().as_uuid().unwrap(),
|
||||||
|
UUID::zero()
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("position").unwrap().as_vector3().unwrap(),
|
||||||
|
expected.position
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("summary").unwrap().as_string().unwrap(),
|
||||||
|
"summary"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("version-string").unwrap().as_string().unwrap(),
|
||||||
|
"<3 LibreMetaverse"
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
payload.get("details").unwrap().as_string().unwrap(),
|
||||||
|
"details"
|
||||||
|
);
|
||||||
|
assert!(!requests.iter().any(|request| request.uri.0 == SEND));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn live_report_submission_requires_explicit_opt_in() {
|
||||||
|
let live_cap = "https://example.com/send-report";
|
||||||
|
let (client, requests) = fixture(&[("SendUserReport", live_cap)], OSD::Map(HashMap::new()));
|
||||||
|
|
||||||
|
let error = block_on(
|
||||||
|
client
|
||||||
|
.user_reports()
|
||||||
|
.send_user_report(report(UUID::zero()), None),
|
||||||
|
)
|
||||||
|
.expect_err("live report must be gated");
|
||||||
|
assert_eq!(error, Error::InvalidOperation);
|
||||||
|
assert!(
|
||||||
|
!requests
|
||||||
|
.lock()
|
||||||
|
.expect("request recording")
|
||||||
|
.iter()
|
||||||
|
.any(|request| request.uri.0 == live_cap)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn category_request_propagates_preexisting_cancellation() {
|
||||||
|
let (client, _) = fixture(&[("AbuseCategories", CATEGORIES)], OSD::Map(HashMap::new()));
|
||||||
|
let cancellation = CancellationTokenSource::new();
|
||||||
|
cancellation.cancel();
|
||||||
|
|
||||||
|
let error = block_on(
|
||||||
|
client
|
||||||
|
.user_reports()
|
||||||
|
.fetch_abuse_report_categories(None, Some(cancellation.token())),
|
||||||
|
)
|
||||||
|
.expect_err("cancellation must be propagated");
|
||||||
|
assert_eq!(error, Error::Cancelled);
|
||||||
|
}
|
||||||
@@ -1,4 +1,6 @@
|
|||||||
use libremetaverse::marketplace::{MarketplaceListing, MarketplaceManager};
|
use libremetaverse::marketplace::{
|
||||||
|
MarketplaceListing, MarketplaceListingStatus, MarketplaceManager,
|
||||||
|
};
|
||||||
use libremetaverse::{
|
use libremetaverse::{
|
||||||
DirectoryManager, DirectoryManagerAgentSearchData, EstateTask, EstateTools, FriendsManager,
|
DirectoryManager, DirectoryManagerAgentSearchData, EstateTask, EstateTools, FriendsManager,
|
||||||
GroupManager, GroupProfileEventArgs, ObjectManager, ParcelManager, ParcelSettings, Simulator,
|
GroupManager, GroupProfileEventArgs, ObjectManager, ParcelManager, ParcelSettings, Simulator,
|
||||||
@@ -9,14 +11,6 @@ use libremetaverse_types::compat::{
|
|||||||
CancellationToken, EventHandler, IAsyncEnumerable, Subscription,
|
CancellationToken, EventHandler, IAsyncEnumerable, Subscription,
|
||||||
};
|
};
|
||||||
|
|
||||||
fn member_id<T>(result: Result<T, libremetaverse::Error>) -> &'static str {
|
|
||||||
result
|
|
||||||
.err()
|
|
||||||
.expect("failure-only shim unexpectedly returned a value")
|
|
||||||
.csharp_member()
|
|
||||||
.expect("failure-only shim error")
|
|
||||||
}
|
|
||||||
|
|
||||||
fn compile_search_call(directory: &DirectoryManager, token: CancellationToken) {
|
fn compile_search_call(directory: &DirectoryManager, token: CancellationToken) {
|
||||||
let _: Result<IAsyncEnumerable<DirectoryManagerAgentSearchData>, libremetaverse::Error> =
|
let _: Result<IAsyncEnumerable<DirectoryManagerAgentSearchData>, libremetaverse::Error> =
|
||||||
directory.search_people(String::new(), Some(token));
|
directory.search_people(String::new(), Some(token));
|
||||||
@@ -67,15 +61,17 @@ fn world_and_social_flows_have_typed_callable_signatures() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn world_facing_constructors_preserve_implemented_and_pending_behavior() {
|
fn world_facing_constructors_preserve_implemented_defaults() {
|
||||||
assert_eq!(
|
let task = EstateTask::new().expect("EstateTask constructor");
|
||||||
member_id(EstateTask::new()),
|
assert_eq!(task.task_id, UUID::zero());
|
||||||
"M:LibreMetaverse.EstateTask.#ctor"
|
assert_eq!(task.task_local_id, 0);
|
||||||
);
|
assert_eq!((task.score, task.mono_score), (0.0, 0.0));
|
||||||
assert_eq!(
|
assert!(task.task_name.is_empty());
|
||||||
member_id(MarketplaceListing::new()),
|
assert!(task.owner_name.is_empty());
|
||||||
"M:LibreMetaverse.Marketplace.MarketplaceListing.#ctor"
|
let listing = MarketplaceListing::new().expect("MarketplaceListing constructor");
|
||||||
);
|
assert_eq!(listing.status(), MarketplaceListingStatus::Unknown);
|
||||||
|
assert_eq!(listing.listing_id(), 0);
|
||||||
|
assert!(listing.edit_url().is_none());
|
||||||
let parcel = ParcelSettings::new().expect("ParcelSettings constructor");
|
let parcel = ParcelSettings::new().expect("ParcelSettings constructor");
|
||||||
assert!(parcel.track_parcels);
|
assert!(parcel.track_parcels);
|
||||||
assert!(parcel.always_request_acl);
|
assert!(parcel.always_request_acl);
|
||||||
|
|||||||
@@ -78,6 +78,12 @@ NATIVE_TYPES = {
|
|||||||
"T:LibreMetaverse.InventoryMaterial": "crate::inventory::InventoryMaterial",
|
"T:LibreMetaverse.InventoryMaterial": "crate::inventory::InventoryMaterial",
|
||||||
"T:LibreMetaverse.InventoryNode": "crate::inventory::InventoryNode",
|
"T:LibreMetaverse.InventoryNode": "crate::inventory::InventoryNode",
|
||||||
"T:LibreMetaverse.InventoryNodeDictionary": "crate::inventory::InventoryNodeDictionary",
|
"T:LibreMetaverse.InventoryNodeDictionary": "crate::inventory::InventoryNodeDictionary",
|
||||||
|
"T:LibreMetaverse.Marketplace.MarketplaceErrorEventArgs": "crate::marketplace_runtime::MarketplaceErrorEventArgs",
|
||||||
|
"T:LibreMetaverse.Marketplace.MarketplaceFolderClassifier": "crate::marketplace_runtime::MarketplaceFolderClassifier",
|
||||||
|
"T:LibreMetaverse.Marketplace.MarketplaceListing": "crate::marketplace_runtime::MarketplaceListing",
|
||||||
|
"T:LibreMetaverse.Marketplace.MarketplaceListingChangedEventArgs": "crate::marketplace_runtime::MarketplaceListingChangedEventArgs",
|
||||||
|
"T:LibreMetaverse.Marketplace.MarketplaceListingsSyncedEventArgs": "crate::marketplace_runtime::MarketplaceListingsSyncedEventArgs",
|
||||||
|
"T:LibreMetaverse.Marketplace.MarketplaceManager": "crate::marketplace_runtime::MarketplaceManager",
|
||||||
"T:LibreMetaverse.InventoryNotecard": "crate::inventory::InventoryNotecard",
|
"T:LibreMetaverse.InventoryNotecard": "crate::inventory::InventoryNotecard",
|
||||||
"T:LibreMetaverse.InventoryObject": "crate::inventory::InventoryObject",
|
"T:LibreMetaverse.InventoryObject": "crate::inventory::InventoryObject",
|
||||||
"T:LibreMetaverse.InventoryObjectAddedEventArgs": "crate::inventory::InventoryObjectAddedEventArgs",
|
"T:LibreMetaverse.InventoryObjectAddedEventArgs": "crate::inventory::InventoryObjectAddedEventArgs",
|
||||||
@@ -143,6 +149,18 @@ NATIVE_TYPES = {
|
|||||||
"T:LibreMetaverse.Assets.GestureStepWait": "crate::gesture::GestureStepWait",
|
"T:LibreMetaverse.Assets.GestureStepWait": "crate::gesture::GestureStepWait",
|
||||||
"T:LibreMetaverse.ExperienceInfoEventArgs": "crate::agent_messages::ExperienceInfoEventArgs",
|
"T:LibreMetaverse.ExperienceInfoEventArgs": "crate::agent_messages::ExperienceInfoEventArgs",
|
||||||
"T:LibreMetaverse.ExperiencePreferencesEventArgs": "crate::agent_messages::ExperiencePreferencesEventArgs",
|
"T:LibreMetaverse.ExperiencePreferencesEventArgs": "crate::agent_messages::ExperiencePreferencesEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateBansReplyEventArgs": "crate::estate_tools::EstateBansReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateCovenantReplyEventArgs": "crate::estate_tools::EstateCovenantReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateExperienceReplyEventArgs": "crate::estate_tools::EstateExperienceReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateGroupsReplyEventArgs": "crate::estate_tools::EstateGroupsReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateManagersReplyEventArgs": "crate::estate_tools::EstateManagersReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateTask": "crate::estate_tools::EstateTask",
|
||||||
|
"T:LibreMetaverse.EstateTools": "crate::estate_tools::EstateTools",
|
||||||
|
"T:LibreMetaverse.EstateUpdateInfoReplyEventArgs": "crate::estate_tools::EstateUpdateInfoReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.EstateUsersReplyEventArgs": "crate::estate_tools::EstateUsersReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.RegionRestartSchedule": "crate::estate_tools::RegionRestartSchedule",
|
||||||
|
"T:LibreMetaverse.TopCollidersReplyEventArgs": "crate::estate_tools::TopCollidersReplyEventArgs",
|
||||||
|
"T:LibreMetaverse.TopScriptsReplyEventArgs": "crate::estate_tools::TopScriptsReplyEventArgs",
|
||||||
"T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs",
|
"T:LibreMetaverse.GroupChatJoinedEventArgs": "crate::agent_messages::GroupChatJoinedEventArgs",
|
||||||
"T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs",
|
"T:LibreMetaverse.RegionExperiencesEventArgs": "crate::agent_messages::RegionExperiencesEventArgs",
|
||||||
"T:LibreMetaverse.SetDisplayNameReplyEventArgs": "crate::agent_messages::SetDisplayNameReplyEventArgs",
|
"T:LibreMetaverse.SetDisplayNameReplyEventArgs": "crate::agent_messages::SetDisplayNameReplyEventArgs",
|
||||||
@@ -456,6 +474,8 @@ NATIVE_DECLARATIONS = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
NATIVE_MEMBER_BODIES = {
|
NATIVE_MEMBER_BODIES = {
|
||||||
|
"P:LibreMetaverse.GridClient.Estate": "self.native_estate().unwrap_or_else(|_| panic!(\"failed to construct EstateTools\"))",
|
||||||
|
"P:LibreMetaverse.GridClient.Estate#set": "self.native_set_estate(value)",
|
||||||
"P:LibreMetaverse.GridClient.Environment":
|
"P:LibreMetaverse.GridClient.Environment":
|
||||||
"self.native_environment().unwrap_or_else(|_| panic!(\"failed to construct EnvironmentManager\"))",
|
"self.native_environment().unwrap_or_else(|_| panic!(\"failed to construct EnvironmentManager\"))",
|
||||||
"P:LibreMetaverse.GridClient.Environment#set": "self.native_set_environment(value)",
|
"P:LibreMetaverse.GridClient.Environment#set": "self.native_set_environment(value)",
|
||||||
|
|||||||
Reference in New Issue
Block a user